diff --git a/README.md b/README.md index 45130aa..183999a 100644 --- a/README.md +++ b/README.md @@ -1 +1,136 @@ # crypto-wallet + +A demo crypto wallet app using with teaching purposes. This app allows clients to manage their crypto currency portfolio. It uses [Coinmarketcap API](https://coinmarketcap.com/api/) for getting the updated cryptocurrencies quotation. + +It provides the following functionalities: + +* managing users (CRUD operations, uses for admins) +* buy, sell and transfer crypto currencies +* allow users to see their portfolio balance (the quotation of their cryptocurrencies portfolio converted to USD) +* allows users to see their transaction history + +## Tech Stack + +* Java 11 +* Spring Boot +* Postgres +* Kafka +* Docker + +## Architecture + +The following diagram shows the systems architecture: + +![Alt text](diagrams/cw-architecture.png?raw=true "Architecture") + +## Data Model + +The following diagram shows the data model: + +![Alt text](diagrams/cw-data-model.png?raw=true "Title") + +## Run the app + +1. Start containers using docker compose: + +``` +docker-compose up +``` + +2. Run the application + +``` +mvn spring-boot:run +``` + +It runs the application on http://localhost:8080 + +## API + +* Healthcheck + +``` +curl -i -H "Accept: application/json" -H "Content-Type: application/json" -X GET http://localhost:8080/healthcheck +``` + +* Get user by id + +``` +curl -i -H "Accept: application/json" -H "Content-Type: application/json" -X GET http://localhost:8080/users/:userId +``` + +* Get all users + +``` +curl -i -H "Accept: application/json" -H "Content-Type: application/json" -X GET http://localhost:8080/users +``` + +* Create user + +``` +curl -X POST -H "Content-Type: application/json" \ + -d '{"username": "pepe", "password": "pass1234", "email": "pepe@gmail.com"}' \ + http://localhost:8080/users +``` + +* Update user + +``` +curl -X PUT -H "Content-Type: application/json" \ + -d '{"username": "pepe", "password": "pass1234", "email": "pepe@gmail.com"}' \ + http://localhost:8080/users/:userId +``` + +* Delete user + +``` +curl -X "DELETE" http://localhost:8080/users/:userId +``` + +* Get quotes + +``` +curl -i -H "Accept: application/json" -H "Content-Type: application/json" -X GET http://localhost:8080/cryptocurrencies/quotes +``` + +* Get portfolio + +``` +curl -i -H "Accept: application/json" -H "Content-Type: application/json" -X GET http://localhost:8080/portfolios/:userId +``` + +* Get transaction history + +``` +curl -i -H "Accept: application/json" -H "Content-Type: application/json" -X GET http://localhost:8080/transactions/8 +``` + +* Transfer + +``` +curl -X POST -H "Content-Type: application/json" \ + -d '{"issuer": "88", "receiver": "2", "cryptocurrency": "Huobi Token", "amount": 10}' \ + http://localhost:8080/transactions/transferences +``` + +* Buy + +``` +curl -X POST -H "Content-Type: application/json" \ + -d '{"userId": 3, "cryptocurrency": "Bitcoin", "amountInUsd": 100000}' \ + http://localhost:8080/transactions/buys +``` + +* Sell + +``` +curl -X POST -H "Content-Type: application/json" \ + -d '{"userId": 3, "cryptocurrency": "Bitcoin", "amount": 1}' \ + http://localhost:8080/transactions/sells +``` + +## Extra notes + +* A ready [docker-compose](docker-compose.yml) is provided. It contains all the components ready for local development. It also contains dummy data for playing around with the app. + +* Also, a [postman collection](postman-collection/crypto.postman_collection.json) is provided. \ No newline at end of file diff --git a/diagrams/cw-architecture.png b/diagrams/cw-architecture.png new file mode 100644 index 0000000..d6f10e8 Binary files /dev/null and b/diagrams/cw-architecture.png differ diff --git a/diagrams/cw-data-model.png b/diagrams/cw-data-model.png new file mode 100644 index 0000000..7f6eba1 Binary files /dev/null and b/diagrams/cw-data-model.png differ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..72f1a60 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,44 @@ +version: '3.7' + +services: + + cw-postgres: + image: postgres:latest + environment: + - "TZ=Europe/Amsterdam" + - "POSTGRES_USER=root" + - "POSTGRES_PASSWORD=root" + - "POSTGRES_DB=cryptodb" + ports: + - 45432:5432 + volumes: + - ./sql:/docker-entrypoint-initdb.d + + cw-adminer: + image: adminer + restart: always + ports: + - 8083:8080 + + cw-zookeeper: + image: wurstmeister/zookeeper + ports: + - 2181:2181 + + cw-kafka: + image: wurstmeister/kafka + ports: + - 9092:9092 + environment: + KAFKA_BROKER_ID: 0 + KAFKA_ZOOKEEPER_CONNECT: cw-zookeeper:2181 + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INSIDE:PLAINTEXT,OUTSIDE:PLAINTEXT + KAFKA_ADVERTISED_LISTENERS: INSIDE://localhost:19092,OUTSIDE://localhost:9092 + KAFKA_LISTENERS: INSIDE://0.0.0.0:19092,OUTSIDE://0.0.0.0:9092 + KAFKA_INTER_BROKER_LISTENER_NAME: INSIDE + KAFKA_LOG_DIRS: /kafka/kafka-logs + KAFKA_DELETE_TOPIC_ENABLE: "true" + KAFKA_DEFAULT_REPLICATION_FACTOR: 1 + KAFKA_NUM_PARTITIONS: 3 + links: + - cw-zookeeper \ No newline at end of file diff --git a/pom.xml b/pom.xml index ddc2169..35f851c 100644 --- a/pom.xml +++ b/pom.xml @@ -19,23 +19,49 @@ org.springframework.boot - spring-boot-starter-data-jdbc + spring-boot-starter-jdbc + org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + + + + org.projectlombok + lombok + 1.18.16 + provided + + org.postgresql postgresql runtime + org.springframework.boot spring-boot-starter-test test + + + org.junit.jupiter + junit-jupiter-engine + test + + + + org.mockito + mockito-junit-jupiter + test + diff --git a/postman-collection/crypto.postman_collection.json b/postman-collection/crypto.postman_collection.json new file mode 100644 index 0000000..c0ef6c7 --- /dev/null +++ b/postman-collection/crypto.postman_collection.json @@ -0,0 +1,281 @@ +{ + "info": { + "_postman_id": "304b1991-0121-4aed-a874-b29e1735c4a3", + "name": "crypto", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "item": [ + { + "name": "healthcheck", + "request": { + "method": "GET", + "header": [], + "url": null + }, + "response": [] + }, + { + "name": "get user by id", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/users/6", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "users", + "6" + ] + } + }, + "response": [] + }, + { + "name": "get users", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/users", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "users" + ] + } + }, + "response": [] + }, + { + "name": "create user", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"pepe\",\n \"password\": \"pass1234\",\n \"email\": \"pepe@gmail.com\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/users", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "users" + ] + } + }, + "response": [] + }, + { + "name": "update user", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"username\":\"pepito\",\n \"password\":\"soypepito\",\n \"email\":\"pepito@pepito\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/users/2", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "users", + "2" + ] + } + }, + "response": [] + }, + { + "name": "delete user", + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "http://localhost:8080/users/15", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "users", + "15" + ] + } + }, + "response": [] + }, + { + "name": "get coins quotes", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/cryptocurrencies/quotes", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "cryptocurrencies", + "quotes" + ] + } + }, + "response": [] + }, + { + "name": "get portfolio", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/portfolios/10", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "portfolios", + "10" + ] + } + }, + "response": [] + }, + { + "name": "get transaction history", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8080/transactions/8", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "transactions", + "8" + ] + } + }, + "response": [] + }, + { + "name": "transfer", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"issuer\": 88,\n \"receiver\": 2,\n \"cryptocurrency\": \"Huobi Token\",\n \"amount\": 6000\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/transactions/transferences", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "transactions", + "transferences" + ] + } + }, + "response": [] + }, + { + "name": "buys", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": 3,\n \"cryptocurrency\": \"Bitcoin\",\n \"amountInUsd\": 100000\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/transactions/buys", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "transactions", + "buys" + ] + } + }, + "response": [] + }, + { + "name": "sells", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"userId\": 3,\n \"cryptocurrency\": \"Bitcoin\",\n \"amount\": 1\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8080/transactions/sells", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8080", + "path": [ + "transactions", + "sells" + ] + } + }, + "response": [] + } + ] +} \ No newline at end of file diff --git a/sql/create-database.sql b/sql/create-database.sql new file mode 100644 index 0000000..b017bd2 --- /dev/null +++ b/sql/create-database.sql @@ -0,0 +1,306 @@ +DROP TABLE IF EXISTS "users"; + +CREATE TABLE IF NOT EXISTS "users" ( + id BIGSERIAL PRIMARY KEY, + username VARCHAR(255) NOT NULL, + password VARCHAR(255) NOT NULL, + email VARCHAR(255) NOT NULL, + enabled BOOLEAN DEFAULT TRUE +); + +DROP TABLE IF EXISTS "cryptocurrency"; + +CREATE TABLE IF NOT EXISTS "cryptocurrency" ( + id BIGSERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + symbol VARCHAR(255) NOT NULL +); + +DROP TABLE IF EXISTS "transaction"; + +CREATE TABLE IF NOT EXISTS "transaction" ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL, + crypto_currency_id INTEGER NOT NULL, + amount NUMERIC(22,0) NOT NULL, + operation_type VARCHAR(50) NOT NULL, + transaction_date VARCHAR(255) NOT NULL, + CONSTRAINT fk_user_id FOREIGN KEY(user_id) REFERENCES "users"(id), + CONSTRAINT fk_cryptocurrency FOREIGN KEY(crypto_currency_id) REFERENCES cryptocurrency(id) +); + +INSERT INTO "cryptocurrency" ("id", "name", "symbol") VALUES +(1, 'Bitcoin', 'BTC'), +(2, 'Ethereum', 'ETH'), +(3, 'Tether', 'USDT'), +(4, 'XRP', 'XRP'), +(5, 'Polkadot', 'DOT'), +(6, 'Cardano', 'ADA'), +(7, 'Chainlink', 'LINK'), +(8, 'Litecoin', 'LTC'), +(9, 'Binance Coin', 'BNB'), +(10, 'Bitcoin Cash', 'BCH'), +(11, 'Stellar', 'XLM'), +(12, 'Dogecoin', 'DOGE'), +(13, 'USD Coin', 'USDC'), +(14, 'Aave', 'AAVE'), +(15, 'Uniswap', 'UNI'), +(16, 'Wrapped Bitcoin', 'WBTC'), +(17, 'Bitcoin SV', 'BSV'), +(18, 'EOS', 'EOS'), +(19, 'Monero', 'XMR'), +(20, 'NEM', 'XEM'), +(21, 'TRON', 'TRX'), +(22, 'Synthetix', 'SNX'), +(23, 'Tezos', 'XTZ'), +(24, 'Maker', 'MKR'), +(25, 'Compound', 'COMP'), +(26, 'THETA', 'THETA'), +(27, 'SushiSwap', 'SUSHI'), +(28, 'Cosmos', 'ATOM'), +(29, 'UMA', 'UMA'), +(30, 'VeChain', 'VET'), +(31, 'Dai', 'DAI'), +(32, 'Solana', 'SOL'), +(33, 'Neo', 'NEO'), +(34, 'Huobi Token', 'HT'), +(35, 'Crypto.com Coin', 'CRO'), +(36, 'Binance USD', 'BUSD'), +(37, 'UNUS SED LEO', 'LEO'), +(38, 'Terra', 'LUNA'), +(39, 'Elrond', 'EGLD'), +(40, 'FTX Token', 'FTT'), +(41, 'IOTA', 'MIOTA'), +(42, 'Avalanche', 'AVAX'), +(43, 'Celsius', 'CEL'), +(44, 'Dash', 'DASH'), +(45, 'Filecoin', 'FIL'), +(46, 'The Graph', 'GRT'), +(47, 'Zcash', 'ZEC'), +(48, 'Kusama', 'KSM'), +(49, 'Revain', 'REV'), +(50, 'Decred', 'DCR'), +(51, 'yearn.finance', 'YFI'), +(52, 'Algorand', 'ALGO'), +(53, 'Ethereum Classic', 'ETC'), +(54, 'Ren', 'REN'), +(55, 'Zilliqa', 'ZIL'), +(56, 'SwissBorg', 'CHSB'), +(57, 'Waves', 'WAVES'), +(58, '0x', 'ZRX'), +(59, 'Nexo', 'NEXO'), +(60, 'NEAR Protocol', 'NEAR'), +(61, 'Curve DAO Token', 'CRV'), +(62, 'Loopring', 'LRC'), +(63, 'OMG Network', 'OMG'), +(64, 'Hedera Hashgraph', 'HBAR'), +(65, 'THORChain', 'RUNE'), +(66, 'renBTC', 'RENBTC'), +(67, '1inch', '1INCH'), +(68, 'Voyager Token', 'VGX'), +(69, 'Celo', 'CELO'), +(70, 'Ontology', 'ONT'), +(71, 'HedgeTrade', 'HEDG'), +(72, 'Basic Attention Token', 'BAT'), +(73, 'HUSD', 'HUSD'), +(74, 'Nano', 'NANO'), +(75, 'ICON', 'ICX'), +(76, 'DigiByte', 'DGB'), +(77, 'Quant', 'QNT'), +(78, 'BitTorrent', 'BTT'), +(79, 'Alpha Finance Lab', 'ALPHA'), +(80, 'Siacoin', 'SC'), +(81, 'Horizen', 'ZEN'), +(82, 'Reserve Rights', 'RSR'), +(83, 'TrueUSD', 'TUSD'), +(84, 'OKB', 'OKB'), +(85, 'Ampleforth', 'AMPL'), +(86, 'Fantom', 'FTM'), +(87, 'Qtum', 'QTUM'), +(88, 'Stacks', 'STX'), +(89, 'Kyber Network', 'KNC'), +(90, 'Enjin Coin', 'ENJ'), +(91, 'Ocean Protocol', 'OCEAN'), +(92, 'PancakeSwap', 'CAKE'), +(93, 'FunFair', 'FUN'), +(94, 'Verge', 'XVG'), +(95, 'Bancor', 'BNT'), +(96, 'IOST', 'IOST'), +(97, 'Decentraland', 'MANA'), +(98, 'TerraUSD', 'UST'), +(99, 'Bitcoin BEP2', 'BTCB'), +(100, 'Paxos Standard', 'PAX'); + +INSERT INTO "users" ("username", "password", "email") VALUES +('freeman.mohr', '0l6lyyy8x', 'Romana.Pagac@gmail.com'), +('tobias.shields', 's26i95xz49e4ko', 'Lasandra.Ward@gmail.com'), +('houston.luettgen', 'lkf9ttsxcy', 'Virgilio.Miller@gmail.com'), +('gladis.trantow', 'fcq9s1rmg', 'Colby.Wiza@gmail.com'), +('anita.flatley', '3y23lwum', 'Clinton.Douglas@gmail.com'), +('belva.nitzsche', 'xrqgmnocosaqm', 'Reynalda.Simonis@gmail.com'), +('modesto.tromp', 'bw8n3cf9', 'Cristobal.Kub@gmail.com'), +('delcie.beatty', 'gb3uyxo4pmk9', 'Renato.Gerlach@gmail.com'), +('harris.heller', 'szzrv2defr', 'Hayden.Goyette@gmail.com'), +('victor.block', '1ywk3oqq', 'Logan.Toy@gmail.com'), +('bobbi.grady', '8yas0ov2scd', 'Laurence.Rosenbaum@gmail.com'), +('rosanna.leffler', '9bvm81mkg', 'Ellsworth.Robel@gmail.com'), +('claud.cole', 'pnysduri10r5', 'Versie.Morar@gmail.com'), +('wilbert.spencer', '1tcw01j6m', 'Curt.Leannon@gmail.com'), +('jody.daniel', 'avb30s61lwkg', 'Brice.Bergnaum@gmail.com'), +('marg.wiza', 'uas1ap2r4xl5', 'Clair.Welch@gmail.com'), +('kaylene.witting', 'bkgxa04sr7ed', 'Soon.Cole@gmail.com'), +('merlene.weimann', 'fd4e1lekm9xiw2', 'Colton.Rogahn@gmail.com'), +('russel.quitzon', 'logsrv9xpp', 'Daina.Kuhlman@gmail.com'), +('demetrius.greenfelder', 'eczqsqh4budxle', 'Michaela.Corkery@gmail.com'), +('denny.bayer', 'arhcaqjd7', 'Cheryll.Borer@gmail.com'), +('gearldine.robel', 'tds399v7n', 'Lanora.Runte@gmail.com'), +('richard.leannon', '2cfiw7zt2v', 'Josiah.O''Connell@gmail.com'), +('dorsey.satterfield', 'mfxzhp3231q3z2c', 'Marceline.Nitzsche@gmail.com'), +('elisabeth.halvorson', 'tdpk0jxbz2st', 'Effie.Harber@gmail.com'), +('fallon.turner', '55d5z8cv', 'Miles.Konopelski@gmail.com'), +('clemente.krajcik', 'ysie202bxm9owu0', 'Oren.Nikolaus@gmail.com'), +('jean.mraz', 'fjqmql4s', 'Romelia.Zieme@gmail.com'), +('hai.klein', 'uil3l3bng', 'Adeline.Halvorson@gmail.com'), +('yvone.kuhlman', 'odwkq329wmpq', 'Christeen.Kirlin@gmail.com'), +('alex.haag', 'hueh2j2yn5', 'Donnell.Zboncak@gmail.com'), +('krista.hessel', 'f3dcof99hbc', 'Garnet.Gottlieb@gmail.com'), +('alethia.schneider', '2ugs3t0rc', 'Darcey.Becker@gmail.com'), +('ferdinand.bauch', 'puy7x7kz3t', 'Crista.Moen@gmail.com'), +('trey.abernathy', 'jpom4f25x19', 'Ben.Cremin@gmail.com'), +('rickey.kilback', '8sdw7o4cor9obkl', 'Shena.Morissette@gmail.com'), +('carlee.rippin', 'mvw1o9sfmhont2', 'Candy.Weimann@gmail.com'), +('eddy.leffler', 'cchggeszk6w0', 'Wilhemina.Marquardt@gmail.com'), +('tanisha.jacobson', 'd9tllti8m5jqo', 'Malorie.Rippin@gmail.com'), +('efren.schaden', 'im958e1y', 'Isiah.Hoppe@gmail.com'), +('clint.bogan', 'ut7smk82nfhld', 'Artie.Fritsch@gmail.com'), +('kim.herzog', 'eaj84aw85ui', 'Katharina.Mraz@gmail.com'), +('virgilio.ondricka', 'v3jnx770rmaskv', 'Tinisha.Deckow@gmail.com'), +('debroah.rath', 'di5zibt6eeono', 'Star.Kohler@gmail.com'), +('meg.barrows', 'u5nd2gzmwzn', 'Donald.Ondricka@gmail.com'), +('bree.terry', 'ycpaiu620qg08', 'Jacques.Bogisich@gmail.com'), +('lesli.kertzmann', 'q5scoutvf04m3g9', 'Antione.Waters@gmail.com'), +('harris.waters', 'vrf4hclmw', 'Rosendo.Jacobi@gmail.com'), +('magan.witting', 'wraf00bxagtm662', 'Andree.Mayer@gmail.com'), +('edison.mraz', 'ox2vmyeufxj8', 'Ngan.Walker@gmail.com'), +('joaquin.denesik', 'jfa3hs3qy', 'Meridith.Gleason@gmail.com'), +('jacki.littel', 'rn3ryupxisuavg', 'Leoma.Keeling@gmail.com'), +('grayce.armstrong', 'ibqalza3psvs', 'Genaro.Doyle@gmail.com'), +('carter.hills', '4sq3ox409oal', 'Sabrina.Kling@gmail.com'), +('reggie.hirthe', '2j0bxs1hvzvg1wm', 'Holley.Boyle@gmail.com'), +('bernard.stroman', '5328isd173g0c95', 'Val.Abbott@gmail.com'), +('monique.mertz', 'tg7953mtq', 'Herbert.Wintheiser@gmail.com'), +('corey.cummerata', 'iebttzxj', 'Julene.Boyle@gmail.com'), +('lorinda.windler', 'yahvq9aq', 'Bernard.Rau@gmail.com'), +('alejandra.hilll', 'zb79kczroxm51c', 'Lula.Boyle@gmail.com'), +('jamika.west', 'jekhea3p1ha', 'Jarred.Kunze@gmail.com'), +('isidro.mraz', 'k46r9kenclep77', 'Jimmy.Abbott@gmail.com'), +('britt.rempel', 'p6qw6138', 'Stacia.Wyman@gmail.com'), +('jocelyn.grimes', 'x176pkj83j', 'Sherrie.Lesch@gmail.com'), +('stanton.raynor', '0bwai8ak2jbb', 'Jung.Walsh@gmail.com'), +('howard.hoeger', '91lzg21178rrns', 'Dara.Schimmel@gmail.com'), +('tressa.willms', 'ynlpv87o4u', 'Blanch.Gislason@gmail.com'), +('jenice.jacobson', '7n7obytvq', 'Georgeanna.Ondricka@gmail.com'), +('noe.yundt', 'c6cogh07ao02', 'Salina.Heathcote@gmail.com'), +('clark.becker', '7kl454rqn', 'Gregg.Sporer@gmail.com'), +('bridgett.hegmann', 'i4ejb7jiv', 'Maribel.Lesch@gmail.com'), +('marica.block', 'y70l8isaj', 'Dominque.Ankunding@gmail.com'), +('jerrell.hudson', 'intfxyg0tqtklnm', 'Rigoberto.Lesch@gmail.com'), +('kiara.koss', 'ra5y4419k', 'Marlin.Abbott@gmail.com'), +('verdell.keeling', '5w235265r8g6ps', 'Jeromy.Heller@gmail.com'), +('modesto.heller', '5xywex1ctuota9', 'Lexie.Lakin@gmail.com'), +('gina.larkin', '9qzvugg7k', 'Rena.Lubowitz@gmail.com'), +('avelina.damore', 'wnjgydgaazwb0n', 'Lucretia.Boehm@gmail.com'), +('karena.buckridge', 'qipe23tvcs', 'Willis.Abshire@gmail.com'), +('celina.hermiston', 'adma7spn', 'Kendall.Dooley@gmail.com'), +('shirlene.dare', 'xdysxj7x', 'Babette.Kilback@gmail.com'), +('juan.hoppe', 'ygj3sidx', 'Javier.Kuphal@gmail.com'), +('phillip.weimann', '7rktwi7xc', 'Lea.Wisozk@gmail.com'), +('teri.dach', 'd68vaom3f0ni3', 'Aron.White@gmail.com'), +('marcelo.sawayn', '3hrkcf6ubidwk5', 'Thaddeus.Farrell@gmail.com'), +('lottie.kautzer', 'm1blbmt9h6wuo6', 'Margarete.Kuphal@gmail.com'), +('tamara.boehm', 'dsnpnc5z', 'Asley.Kertzmann@gmail.com'), +('genaro.batz', 'nsdb1drpk', 'Andre.Champlin@gmail.com'), +('lauren.gerlach', 'fm2izq9cajqq', 'Lonnie.Bogan@gmail.com'), +('valentine.walter', 'wap7lv3ih3izqcl', 'Lakia.Graham@gmail.com'), +('gregory.harvey', 'magvzjbtlkuuw', 'Wiley.Beahan@gmail.com'), +('adalberto.pfannerstill', 'mqcl56y86k139', 'Roberta.Price@gmail.com'), +('ross.mohr', 'gmhisy9v7jj5', 'Jeanmarie.Grimes@gmail.com'), +('abby.mcclure', 'gvi8t20q7e', 'Annalee.McLaughlin@gmail.com'), +('jonna.oconner', '8tao7j9h9i30ng', 'Alison.Sanford@gmail.com'), +('dannie.larson', 'rap6k0sbwjl', 'Sydney.Schumm@gmail.com'), +('muriel.russel', 'qkjkqhjl8', 'Cyril.Sawayn@gmail.com'), +('harlan.larson', 'mlt6jmns0', 'Winona.Brown@gmail.com'), +('analisa.franecki', '0snjt0kjg0r5k', 'Latrina.Carter@gmail.com'), +('eric.dietrich', 'mkrwc894nn9ir2', 'Hershel.Adams@gmail.com'); + +INSERT INTO "transaction" ("user_id", "crypto_currency_id", "amount", "operation_type", "transaction_date") VALUES +(40, 25, 207, 'BUY', '2021-02-05T19:26:30.091'), +(85, 31, 334, 'BUY', '2021-02-05T19:26:30.101'), +(92, 57, 9642, 'BUY', '2021-02-05T19:26:30.105'), +(98, 1, 2606, 'BUY', '2021-02-05T19:28:43.079'), +(69, 39, 8215, 'BUY', '2021-02-05T19:28:43.082'), +(96, 61, 8535, 'BUY', '2021-02-05T19:28:43.086'), +(50, 3, 2559, 'BUY', '2021-02-05T19:28:43.089'), +(13, 42, 193, 'BUY', '2021-02-05T19:28:43.095'), +(20, 46, 3805, 'BUY', '2021-02-05T19:28:43.098'), +(4, 81, 5778, 'BUY', '2021-02-05T19:28:43.104'), +(88, 90, 6471, 'BUY', '2021-02-05T19:28:43.111'), +(84, 13, 1645, 'BUY', '2021-02-05T19:28:43.117'), +(46, 89, 3558, 'BUY', '2021-02-05T19:28:43.128'), +(99, 45, 8986, 'BUY', '2021-02-05T19:28:43.131'), +(88, 69, 5435, 'BUY', '2021-02-05T19:28:43.134'), +(72, 3, 8340, 'BUY', '2021-02-05T19:28:43.137'), +(86, 45, 8465, 'BUY', '2021-02-05T19:28:43.141'), +(24, 96, 6725, 'BUY', '2021-02-05T19:28:43.149'), +(43, 54, 689, 'BUY', '2021-02-05T19:28:43.152'), +(78, 50, 2110, 'BUY', '2021-02-05T19:28:43.156'), +(19, 24, 7530, 'BUY', '2021-02-05T19:28:43.160'), +(53, 98, 3972, 'BUY', '2021-02-05T19:28:43.168'), +(51, 70, 5646, 'BUY', '2021-02-05T19:29:03.203'), +(97, 13, 9228, 'BUY', '2021-02-05T19:29:03.207'), +(40, 31, 8321, 'BUY', '2021-02-05T19:29:03.218'), +(50, 93, 3650, 'BUY', '2021-02-05T19:29:03.223'), +(60, 18, 7363, 'BUY', '2021-02-05T19:29:03.231'), +(21, 49, 8854, 'BUY', '2021-02-05T19:29:03.242'), +(70, 5, 7481, 'BUY', '2021-02-05T19:29:03.256'), +(43, 37, 3791, 'BUY', '2021-02-05T19:29:03.263'), +(99, 5, 5487, 'BUY', '2021-02-05T19:29:03.271'), +(38, 65, 5749, 'BUY', '2021-02-05T19:29:03.288'), +(20, 95, 4628, 'BUY', '2021-02-05T19:29:03.294'), +(88, 34, 5985, 'BUY', '2021-02-05T19:29:08.804'), +(89, 16, 8370, 'BUY', '2021-02-05T19:29:08.825'), +(50, 91, -353, 'SELL', '2021-02-05T19:28:43.068'), +(65, 23, -6060, 'SELL', '2021-02-05T19:28:43.163'), +(68, 91, -6453, 'SELL', '2021-02-05T19:29:03.190'), +(37, 70, -4943, 'SELL', '2021-02-05T19:29:03.199'), +(8, 50, -2637, 'SELL', '2021-02-05T19:29:03.211'), +(75, 2, -2891, 'SELL', '2021-02-05T19:29:03.249'), +(94, 77, -481, 'SELL', '2021-02-05T19:29:03.253'), +(88, 72, -6248, 'SELL', '2021-02-05T19:29:03.267'), +(81, 42, -7979, 'SELL', '2021-02-05T19:29:08.814'), +(95, 65, -4064, 'SELL', '2021-02-05T19:29:08.821'), +(92, 73, -2394, 'SELL', '2021-02-05T19:29:08.828'), +(95, 74, -5233, 'TRANSFERENCE', '2021-02-05T19:26:30.079'), +(78, 45, -6713, 'TRANSFERENCE', '2021-02-05T19:26:30.088'), +(16, 94, -4969, 'TRANSFERENCE', '2021-02-05T19:26:30.097'), +(61, 47, -9508, 'TRANSFERENCE', '2021-02-05T19:28:43.092'), +(66, 3, -5913, 'TRANSFERENCE', '2021-02-05T19:28:43.101'), +(93, 96, -1302, 'TRANSFERENCE', '2021-02-05T19:28:43.107'), +(14, 22, -6734, 'TRANSFERENCE', '2021-02-05T19:28:43.114'), +(48, 98, -5015, 'TRANSFERENCE', '2021-02-05T19:28:43.121'), +(1, 66, -2212, 'TRANSFERENCE', '2021-02-05T19:28:43.125'), +(51, 77, -2776, 'TRANSFERENCE', '2021-02-05T19:28:43.145'), +(77, 62, -6933, 'TRANSFERENCE', '2021-02-05T19:28:43.165'), +(64, 55, -4621, 'TRANSFERENCE', '2021-02-05T19:29:03.215'), +(42, 27, -2335, 'TRANSFERENCE', '2021-02-05T19:29:03.226'), +(24, 8, -2545, 'TRANSFERENCE', '2021-02-05T19:29:03.235'), +(10, 46, -3531, 'TRANSFERENCE', '2021-02-05T19:29:03.239'), +(68, 42, -559, 'TRANSFERENCE', '2021-02-05T19:29:03.245'), +(35, 53, -8838, 'TRANSFERENCE', '2021-02-05T19:29:03.260'), +(56, 7, -16, 'TRANSFERENCE', '2021-02-05T19:29:03.275'), +(76, 33, -423, 'TRANSFERENCE', '2021-02-05T19:29:03.279'), +(48, 87, -4904, 'TRANSFERENCE', '2021-02-05T19:29:03.283'), +(90, 51, -5776, 'TRANSFERENCE', '2021-02-05T19:29:03.291'), +(56, 9, -1794, 'TRANSFERENCE', '2021-02-05T19:29:08.811'), +(59, 65, -973, 'TRANSFERENCE', '2021-02-05T19:29:08.818'), +(95, 13, -8877, 'TRANSFERENCE', '2021-02-05T19:29:08.831'); \ No newline at end of file diff --git a/src/main/java/com/serdeliverance/cryptowallet/api/CryptocurrencyController.java b/src/main/java/com/serdeliverance/cryptowallet/api/CryptocurrencyController.java new file mode 100644 index 0000000..55374a8 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/api/CryptocurrencyController.java @@ -0,0 +1,26 @@ +package com.serdeliverance.cryptowallet.api; + +import com.serdeliverance.cryptowallet.dto.CurrencyQuoteDTO; +import com.serdeliverance.cryptowallet.services.CryptocurrencyService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/cryptocurrencies") +@RequiredArgsConstructor +@Slf4j +public class CryptocurrencyController { + + private final CryptocurrencyService cryptocurrencyService; + + @GetMapping("/quotes") + public List quotes() { + log.info("Getting quotes"); + return cryptocurrencyService.quotes(); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/api/HealthCheckController.java b/src/main/java/com/serdeliverance/cryptowallet/api/HealthCheckController.java new file mode 100644 index 0000000..1d3cea3 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/api/HealthCheckController.java @@ -0,0 +1,18 @@ +package com.serdeliverance.cryptowallet.api; + +import com.serdeliverance.cryptowallet.dto.StatusDTO; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.serdeliverance.cryptowallet.dto.StatusDTO.ok; + +@RestController +@RequestMapping("/healthcheck") +public class HealthCheckController { + + @GetMapping + public StatusDTO healthcheck() { + return ok(); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/api/PortfolioController.java b/src/main/java/com/serdeliverance/cryptowallet/api/PortfolioController.java new file mode 100644 index 0000000..cf96060 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/api/PortfolioController.java @@ -0,0 +1,25 @@ +package com.serdeliverance.cryptowallet.api; + +import com.serdeliverance.cryptowallet.dto.PorfolioDTO; +import com.serdeliverance.cryptowallet.services.PortfolioService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/portfolios") +@RequiredArgsConstructor +@Slf4j +public class PortfolioController { + + private final PortfolioService portfolioService; + + @GetMapping("/{userId}") + public PorfolioDTO getPortfolio(@PathVariable("userId") Integer userId) { + log.info("Getting crypto portfolio for user: {}", userId); + return portfolioService.getPortfolio(userId); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/api/TransactionController.java b/src/main/java/com/serdeliverance/cryptowallet/api/TransactionController.java new file mode 100644 index 0000000..59ee11f --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/api/TransactionController.java @@ -0,0 +1,52 @@ +package com.serdeliverance.cryptowallet.api; + +import com.serdeliverance.cryptowallet.dto.BuyDTO; +import com.serdeliverance.cryptowallet.dto.SellDTO; +import com.serdeliverance.cryptowallet.dto.TransactionDTO; +import com.serdeliverance.cryptowallet.dto.TransferenceDTO; +import com.serdeliverance.cryptowallet.services.TransactionService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import javax.validation.Valid; +import java.util.List; + +@RestController +@RequestMapping("/transactions") +@RequiredArgsConstructor +@Slf4j +public class TransactionController { + + private final TransactionService transactionService; + + @GetMapping("/{userId}") + public List getHistory(@PathVariable("userId") Integer userId) { + log.info("Getting transaction history for userId: {}", userId); + return transactionService.getHistory(userId); + } + + @PostMapping("/transferences") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void transfer(@RequestBody TransferenceDTO transferenceDTO) { + log.info("Performing transference from issuer {} to receiver {}", + transferenceDTO.getIssuer(), transferenceDTO.getReceiver()); + transactionService.transfer(transferenceDTO.getIssuer(), transferenceDTO.getReceiver(), + transferenceDTO.getCryptocurrency(), transferenceDTO.getAmount()); + } + + @PostMapping("/buys") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void buy(@Valid @RequestBody BuyDTO buyDTO) { + log.info("Perfoming buy by user {}", buyDTO.getUserId()); + transactionService.buy(buyDTO.getUserId(), buyDTO.getCryptocurrency(), buyDTO.getAmountInUsd()); + } + + @PostMapping("/sells") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void buy(@Valid @RequestBody SellDTO sellDTO) { + log.info("Perfoming selling by user {}", sellDTO.getUserId()); + transactionService.sell(sellDTO.getUserId(), sellDTO.getCryptocurrency(), sellDTO.getAmount()); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/api/UserController.java b/src/main/java/com/serdeliverance/cryptowallet/api/UserController.java new file mode 100644 index 0000000..6767cce --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/api/UserController.java @@ -0,0 +1,65 @@ +package com.serdeliverance.cryptowallet.api; + +import com.serdeliverance.cryptowallet.converters.UserDTOConverter; +import com.serdeliverance.cryptowallet.dto.CreateUserDTO; +import com.serdeliverance.cryptowallet.dto.UpdateUserDTO; +import com.serdeliverance.cryptowallet.dto.UserDTO; +import com.serdeliverance.cryptowallet.exceptions.ResourceNotFoundException; +import com.serdeliverance.cryptowallet.services.UserService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.stream.Collectors; + +import static com.serdeliverance.cryptowallet.converters.UserDTOConverter.convertToModel; + +@RestController +@RequestMapping("/users") +@RequiredArgsConstructor +@Slf4j +public class UserController { + + private final UserService userService; + + @GetMapping("/{id}") + public UserDTO get(@PathVariable("id") Integer id) { + log.info("Getting user with id: {}", id); + return userService + .get(id) + .map(UserDTOConverter::convertToDTO) + .orElseThrow(() -> new ResourceNotFoundException("user:" + id)); + } + + @GetMapping + public List getAll() { + log.info("Getting all users"); + return userService + .getAll() + .stream() + .map(UserDTOConverter::convertToDTO) + .collect(Collectors.toList()); + } + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public void create(@RequestBody CreateUserDTO user) { + log.info("creating user..."); + userService.create(convertToModel(user)); + } + + @PutMapping("/{id}") + public void update(@PathVariable("id") Integer id, @RequestBody UpdateUserDTO updateUser) { + log.info("updating user: {}", id); + userService.update(convertToModel(id, updateUser)); + } + + @DeleteMapping("/{id}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void delete(@PathVariable("id") Integer id) { + log.info("deleting user with userId: {}", id); + userService.delete(id); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/api/exception/ApiExceptionHandler.java b/src/main/java/com/serdeliverance/cryptowallet/api/exception/ApiExceptionHandler.java new file mode 100644 index 0000000..b8d494f --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/api/exception/ApiExceptionHandler.java @@ -0,0 +1,42 @@ +package com.serdeliverance.cryptowallet.api.exception; + +import com.serdeliverance.cryptowallet.exceptions.InvalidOperationException; +import com.serdeliverance.cryptowallet.exceptions.RemoteApiException; +import com.serdeliverance.cryptowallet.exceptions.ResourceNotFoundException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +@Slf4j +public class ApiExceptionHandler { + + @ExceptionHandler(value = {RuntimeException.class, RemoteApiException.class}) + public ResponseEntity internalServerError( + RuntimeException runtimeException + ) { + log.error("Unexpected error. Error {}", runtimeException.getMessage()); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR).build(); + } + + @ExceptionHandler(value = {ResourceNotFoundException.class}) + public ResponseEntity notFoundError( + ResourceNotFoundException resourceNotFoundException + ) { + log.info("Resource not found. {}", resourceNotFoundException.getMessage()); + return ResponseEntity + .status(HttpStatus.NOT_FOUND).build(); + } + + @ExceptionHandler(value = {InvalidOperationException.class}) + public ResponseEntity invalidOperation( + InvalidOperationException invalidOperationException + ) { + log.info("Invalid operation. {}", invalidOperationException.getMessage()); + return ResponseEntity + .status(HttpStatus.BAD_REQUEST).build(); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/clients/CoinmarketCapClient.java b/src/main/java/com/serdeliverance/cryptowallet/clients/CoinmarketCapClient.java new file mode 100644 index 0000000..8fe86de --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/clients/CoinmarketCapClient.java @@ -0,0 +1,47 @@ +package com.serdeliverance.cryptowallet.clients; + +import com.serdeliverance.cryptowallet.clients.response.ListingQuotesResponseDTO; +import com.serdeliverance.cryptowallet.exceptions.RemoteApiException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CoinmarketCapClient { + + private static final String API_KEY_HEADER = "X-CMC_PRO_API_KEY"; + + private final RestTemplate restTemplate; + + @Value("${coinmarketcap.api-key}") + private String apiKey; + + @Value("${coinmarketcap.url}") + private String url; + + public ListingQuotesResponseDTO quotes() { + log.info("Getting quotes from coinmarketcap"); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, createEntityWithHeader(API_KEY_HEADER, apiKey), ListingQuotesResponseDTO.class); + return handleResponse(response); + } + + private ListingQuotesResponseDTO handleResponse(ResponseEntity response) { + if (response.getStatusCode() != HttpStatus.OK) { + log.error("Coinmarketcap responded with {} status code", response.getStatusCodeValue()); + throw new RemoteApiException("error communicating with coinmarketcap API"); + } + return response.getBody(); + } + + private HttpEntity createEntityWithHeader(String header, String value) { + HttpHeaders headers = new HttpHeaders(); + headers.add(header, value); + HttpEntity entity = new HttpEntity<>(null, headers); + return entity; + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/clients/response/ListingElementDTO.java b/src/main/java/com/serdeliverance/cryptowallet/clients/response/ListingElementDTO.java new file mode 100644 index 0000000..861756f --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/clients/response/ListingElementDTO.java @@ -0,0 +1,16 @@ +package com.serdeliverance.cryptowallet.clients.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ListingElementDTO { + private String name; + private String symbol; + private Map quote; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/clients/response/ListingQuotesResponseDTO.java b/src/main/java/com/serdeliverance/cryptowallet/clients/response/ListingQuotesResponseDTO.java new file mode 100644 index 0000000..3fabc56 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/clients/response/ListingQuotesResponseDTO.java @@ -0,0 +1,14 @@ +package com.serdeliverance.cryptowallet.clients.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class ListingQuotesResponseDTO { + private List data; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/clients/response/QuoteDTO.java b/src/main/java/com/serdeliverance/cryptowallet/clients/response/QuoteDTO.java new file mode 100644 index 0000000..6deb432 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/clients/response/QuoteDTO.java @@ -0,0 +1,14 @@ +package com.serdeliverance.cryptowallet.clients.response; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class QuoteDTO { + private BigDecimal price; +} \ No newline at end of file diff --git a/src/main/java/com/serdeliverance/cryptowallet/configurations/AppConfiguration.java b/src/main/java/com/serdeliverance/cryptowallet/configurations/AppConfiguration.java new file mode 100644 index 0000000..ba2ecff --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/configurations/AppConfiguration.java @@ -0,0 +1,22 @@ +package com.serdeliverance.cryptowallet.configurations; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.web.client.RestTemplate; + +import javax.sql.DataSource; + +@Configuration +public class AppConfiguration { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } + + @Bean + public NamedParameterJdbcTemplate namedParameterJdbcTemplate(DataSource ds) { + return new NamedParameterJdbcTemplate(ds); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/converters/CurrencyQuoteDTOConverter.java b/src/main/java/com/serdeliverance/cryptowallet/converters/CurrencyQuoteDTOConverter.java new file mode 100644 index 0000000..92493a6 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/converters/CurrencyQuoteDTOConverter.java @@ -0,0 +1,19 @@ +package com.serdeliverance.cryptowallet.converters; + +import com.serdeliverance.cryptowallet.clients.response.ListingQuotesResponseDTO; +import com.serdeliverance.cryptowallet.dto.CurrencyQuoteDTO; + +import java.util.List; +import java.util.stream.Collectors; + +public class CurrencyQuoteDTOConverter { + + private static final String USD_CURRENCY = "USD"; + + public static List convertFromResponse(ListingQuotesResponseDTO response) { + return response.getData() + .stream() + .map(elem -> new CurrencyQuoteDTO(elem.getName(), elem.getQuote().get(USD_CURRENCY).getPrice())) + .collect(Collectors.toList()); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/converters/UserDTOConverter.java b/src/main/java/com/serdeliverance/cryptowallet/converters/UserDTOConverter.java new file mode 100644 index 0000000..205eba1 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/converters/UserDTOConverter.java @@ -0,0 +1,39 @@ +package com.serdeliverance.cryptowallet.converters; + +import com.serdeliverance.cryptowallet.domain.User; +import com.serdeliverance.cryptowallet.dto.CreateUserDTO; +import com.serdeliverance.cryptowallet.dto.UpdateUserDTO; +import com.serdeliverance.cryptowallet.dto.UserDTO; + +import java.util.Optional; + +/** + * Converter for UserDTO to User and viceversa + */ +public class UserDTOConverter { + + public static UserDTO convertToDTO(User user) { + return new UserDTO( + user.getId(), + user.getUsername(), + user.getEmail() + ); + } + + public static User convertToModel(CreateUserDTO createUserDTO) { + return new User( + Optional.empty(), + createUserDTO.getUsername(), + createUserDTO.getPassword(), + createUserDTO.getEmail()); + } + + public static User convertToModel(Integer id, UpdateUserDTO updateUserDTO) { + return new User( + Optional.of(id), + updateUserDTO.getUsername(), + updateUserDTO.getPassword(), + updateUserDTO.getEmail() + ); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/domain/Cryptocurrency.java b/src/main/java/com/serdeliverance/cryptowallet/domain/Cryptocurrency.java new file mode 100644 index 0000000..9aafe03 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/domain/Cryptocurrency.java @@ -0,0 +1,14 @@ +package com.serdeliverance.cryptowallet.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Cryptocurrency { + private Integer id; + private String name; + private String symbol; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/domain/CurrencyTotal.java b/src/main/java/com/serdeliverance/cryptowallet/domain/CurrencyTotal.java new file mode 100644 index 0000000..1996a9e --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/domain/CurrencyTotal.java @@ -0,0 +1,13 @@ +package com.serdeliverance.cryptowallet.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CurrencyTotal { + private Cryptocurrency cryptocurrency; + private Integer amount; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/domain/OperationType.java b/src/main/java/com/serdeliverance/cryptowallet/domain/OperationType.java new file mode 100644 index 0000000..d1b2a46 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/domain/OperationType.java @@ -0,0 +1,7 @@ +package com.serdeliverance.cryptowallet.domain; + +public enum OperationType { + BUY, + SELL, + TRANSFERENCE +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/domain/Portfolio.java b/src/main/java/com/serdeliverance/cryptowallet/domain/Portfolio.java new file mode 100644 index 0000000..08c1e25 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/domain/Portfolio.java @@ -0,0 +1,19 @@ +package com.serdeliverance.cryptowallet.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Portfolio { + private User user; + private List currencies; + private BigDecimal totalInUSD; + private LocalDateTime date; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/domain/Transaction.java b/src/main/java/com/serdeliverance/cryptowallet/domain/Transaction.java new file mode 100644 index 0000000..2d61157 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/domain/Transaction.java @@ -0,0 +1,20 @@ +package com.serdeliverance.cryptowallet.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Transaction { + + private Long id; + private Integer userId; + private Integer cryptocurrencyId; + private BigDecimal amount; + private OperationType operationType; + private String transactionDate; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/domain/User.java b/src/main/java/com/serdeliverance/cryptowallet/domain/User.java new file mode 100644 index 0000000..3f9bc5e --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/domain/User.java @@ -0,0 +1,18 @@ +package com.serdeliverance.cryptowallet.domain; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class User { + + private Optional id; + private String username; + private String password; + private String email; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/BuyDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/BuyDTO.java new file mode 100644 index 0000000..6851442 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/BuyDTO.java @@ -0,0 +1,20 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import javax.validation.constraints.Min; +import java.math.BigDecimal; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class BuyDTO { + + private Integer userId; + private String cryptocurrency; + + @Min(value = 1, message = "Amount in usd must be valid") + private BigDecimal amountInUsd; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/CreateUserDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/CreateUserDTO.java new file mode 100644 index 0000000..ca742ca --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/CreateUserDTO.java @@ -0,0 +1,17 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CreateUserDTO { + private Optional id; + private String username; + private String password; + private String email; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/CurrencyQuoteDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/CurrencyQuoteDTO.java new file mode 100644 index 0000000..b351901 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/CurrencyQuoteDTO.java @@ -0,0 +1,15 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CurrencyQuoteDTO { + private String crypto; + private BigDecimal quoteInUsd; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/CurrencyTotalDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/CurrencyTotalDTO.java new file mode 100644 index 0000000..36ae001 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/CurrencyTotalDTO.java @@ -0,0 +1,15 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class CurrencyTotalDTO { + private String currency; + private BigDecimal amount; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/PorfolioDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/PorfolioDTO.java new file mode 100644 index 0000000..dcfe493 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/PorfolioDTO.java @@ -0,0 +1,25 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Collections; +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class PorfolioDTO { + + private Integer userId; + private List currencies; + private BigDecimal totalInUSD; + private LocalDateTime date; + + public static PorfolioDTO emptyPortfolio(Integer userId, LocalDateTime date) { + return new PorfolioDTO(userId, Collections.emptyList(), BigDecimal.ZERO, date); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/SellDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/SellDTO.java new file mode 100644 index 0000000..8b333a0 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/SellDTO.java @@ -0,0 +1,16 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class SellDTO { + private Integer userId; + private String cryptocurrency; + private BigDecimal amount; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/StatusDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/StatusDTO.java new file mode 100644 index 0000000..129a74c --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/StatusDTO.java @@ -0,0 +1,17 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class StatusDTO { + + private static final String OK_MSG = "ok"; + + private String status; + + public static StatusDTO ok() { + return new StatusDTO(OK_MSG); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/TransactionDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/TransactionDTO.java new file mode 100644 index 0000000..75250ae --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/TransactionDTO.java @@ -0,0 +1,18 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class TransactionDTO { + private Long id; + private String cryptocurrency; + private BigDecimal amount; + private String operationType; + private String date; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/TransferenceDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/TransferenceDTO.java new file mode 100644 index 0000000..1c933ac --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/TransferenceDTO.java @@ -0,0 +1,17 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.math.BigDecimal; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class TransferenceDTO { + private Integer issuer; + private Integer receiver; + private String cryptocurrency; + private BigDecimal amount; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/UpdateUserDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/UpdateUserDTO.java new file mode 100644 index 0000000..22590f4 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/UpdateUserDTO.java @@ -0,0 +1,17 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UpdateUserDTO { + private Optional id; + private String username; + private String password; + private String email; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/dto/UserDTO.java b/src/main/java/com/serdeliverance/cryptowallet/dto/UserDTO.java new file mode 100644 index 0000000..d13cdb2 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/dto/UserDTO.java @@ -0,0 +1,16 @@ +package com.serdeliverance.cryptowallet.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Optional; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserDTO { + private Optional id; + private String username; + private String email; +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/exceptions/InvalidOperationException.java b/src/main/java/com/serdeliverance/cryptowallet/exceptions/InvalidOperationException.java new file mode 100644 index 0000000..cb19cab --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/exceptions/InvalidOperationException.java @@ -0,0 +1,7 @@ +package com.serdeliverance.cryptowallet.exceptions; + +public class InvalidOperationException extends RuntimeException { + public InvalidOperationException(String message) { + super(message); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/exceptions/RemoteApiException.java b/src/main/java/com/serdeliverance/cryptowallet/exceptions/RemoteApiException.java new file mode 100644 index 0000000..5502420 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/exceptions/RemoteApiException.java @@ -0,0 +1,8 @@ +package com.serdeliverance.cryptowallet.exceptions; + +public class RemoteApiException extends RuntimeException { + + public RemoteApiException(String message) { + super(message); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/exceptions/ResourceNotFoundException.java b/src/main/java/com/serdeliverance/cryptowallet/exceptions/ResourceNotFoundException.java new file mode 100644 index 0000000..c347d92 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/exceptions/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.serdeliverance.cryptowallet.exceptions; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/repositories/CryptocurrencyRepository.java b/src/main/java/com/serdeliverance/cryptowallet/repositories/CryptocurrencyRepository.java new file mode 100644 index 0000000..a9db9a9 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/repositories/CryptocurrencyRepository.java @@ -0,0 +1,39 @@ +package com.serdeliverance.cryptowallet.repositories; + +import com.serdeliverance.cryptowallet.domain.Cryptocurrency; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.jdbc.core.namedparam.MapSqlParameterSource; +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.jdbc.core.namedparam.SqlParameterSource; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class CryptocurrencyRepository { + + private final JdbcTemplate jdbcTemplate; + private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; + + private RowMapper mapper = + (rs, rowNum) -> new Cryptocurrency(rs.getInt("id"), rs.getString("name"), rs.getString("symbol")); + + public List getByIdList(List ids) { + SqlParameterSource parameters = new MapSqlParameterSource("ids", ids); + return namedParameterJdbcTemplate + .query("SELECT ID, NAME, SYMBOL FROM CRYPTOCURRENCY WHERE ID IN (:ids)", + parameters, mapper); + } + + public Optional getByName(String name) { + return jdbcTemplate.query( + "SELECT ID, NAME, SYMBOL FROM CRYPTOCURRENCY WHERE NAME = ?", + mapper, name).stream().findFirst(); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/repositories/TransactionRepository.java b/src/main/java/com/serdeliverance/cryptowallet/repositories/TransactionRepository.java new file mode 100644 index 0000000..6ad5f9b --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/repositories/TransactionRepository.java @@ -0,0 +1,39 @@ +package com.serdeliverance.cryptowallet.repositories; + +import com.serdeliverance.cryptowallet.domain.OperationType; +import com.serdeliverance.cryptowallet.domain.Transaction; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +@Repository +@Slf4j +@RequiredArgsConstructor +public class TransactionRepository { + + private final JdbcTemplate jdbcTemplate; + + public List getByUser(Integer userId) { + + return jdbcTemplate.query("SELECT ID, USER_ID, CRYPTO_CURRENCY_ID, AMOUNT, OPERATION_TYPE, TRANSACTION_DATE FROM TRANSACTION WHERE USER_ID = ?", + (rs, rowNum) -> + new Transaction( + rs.getLong("id"), + rs.getInt("user_id"), + rs.getInt("crypto_currency_id"), + rs.getBigDecimal("amount"), + OperationType.valueOf(rs.getString("operation_type")), + rs.getString("transaction_date")), + userId); + } + + public void saveTransaction(Integer userId, Integer cryptocurrencyId, BigDecimal amount, OperationType operationType, LocalDateTime transactionDate) { + jdbcTemplate.update("INSERT INTO TRANSACTION(USER_ID, CRYPTO_CURRENCY_ID, AMOUNT, OPERATION_TYPE, TRANSACTION_DATE) VALUES(?, ?, ?, ?, ?)", + userId, cryptocurrencyId, amount, operationType.name(), transactionDate.toString()); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/repositories/UserRepository.java b/src/main/java/com/serdeliverance/cryptowallet/repositories/UserRepository.java new file mode 100644 index 0000000..7155fa9 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/repositories/UserRepository.java @@ -0,0 +1,51 @@ +package com.serdeliverance.cryptowallet.repositories; + +import com.serdeliverance.cryptowallet.domain.User; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public class UserRepository { + + private JdbcTemplate jdbcTemplate; + + public UserRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + private RowMapper userRowMapper = (rs, rowNum) -> new User(Optional.of(rs.getInt("ID")), rs.getString("USERNAME"), rs.getString("PASSWORD"), rs.getString("EMAIL")); + + public Optional find(Integer id) { + return jdbcTemplate.query( + "SELECT ID, USERNAME, PASSWORD, EMAIL FROM USERS WHERE ENABLED = TRUE AND ID = ?", + userRowMapper, id).stream().findFirst(); + } + + public List getAll() { + return jdbcTemplate.query("SELECT ID, USERNAME, PASSWORD, EMAIL FROM USERS WHERE ENABLED = TRUE", userRowMapper); + } + + public void save(User user) { + jdbcTemplate.update( + "INSERT INTO USERS(USERNAME, PASSWORD, EMAIL) VALUES(?, ?, ?)", + user.getUsername(), + user.getPassword(), + user.getEmail()); + } + + public void update(User user) { + jdbcTemplate.update("UPDATE USERS SET USERNAME = ?, PASSWORD = ?, EMAIL = ? WHERE ID = ?", + user.getUsername(), + user.getPassword(), + user.getEmail(), + user.getId().get()); + } + + public void delete(Integer userId) { + jdbcTemplate.update("UPDATE USERS SET ENABLED = FALSE WHERE ID = ?", userId); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/services/CryptocurrencyService.java b/src/main/java/com/serdeliverance/cryptowallet/services/CryptocurrencyService.java new file mode 100644 index 0000000..8c45f97 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/services/CryptocurrencyService.java @@ -0,0 +1,46 @@ +package com.serdeliverance.cryptowallet.services; + +import com.serdeliverance.cryptowallet.clients.CoinmarketCapClient; +import com.serdeliverance.cryptowallet.domain.Cryptocurrency; +import com.serdeliverance.cryptowallet.dto.CurrencyQuoteDTO; +import com.serdeliverance.cryptowallet.exceptions.ResourceNotFoundException; +import com.serdeliverance.cryptowallet.repositories.CryptocurrencyRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; + +import static com.serdeliverance.cryptowallet.converters.CurrencyQuoteDTOConverter.convertFromResponse; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CryptocurrencyService { + + private final CoinmarketCapClient coinmarketCapClient; + private final CryptocurrencyRepository cryptocurrencyRepository; + + public List quotes() { + log.info("Getting quotes"); + return convertFromResponse(coinmarketCapClient.quotes()); + } + + public List getByIdList(List ids) { + log.info("Getting all cryptocurrencies with ids: {}", ids); + return cryptocurrencyRepository.getByIdList(ids); + } + + public Cryptocurrency getByName(String cryptocurrency) { + log.info("Searching cryptocurrency: {}", cryptocurrency); + return cryptocurrencyRepository + .getByName(cryptocurrency) + .orElseThrow(() -> new ResourceNotFoundException("not found crypto currency with name: " + cryptocurrency)); + } + + public CurrencyQuoteDTO getQuote(String cryptocurrency) { + return quotes().stream() + .filter(crypto -> crypto.getCrypto().equals(cryptocurrency)) + .findFirst().orElseThrow(() -> new ResourceNotFoundException("not found quote for cryptocurrency: " + cryptocurrency)); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/services/PortfolioService.java b/src/main/java/com/serdeliverance/cryptowallet/services/PortfolioService.java new file mode 100644 index 0000000..959b28b --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/services/PortfolioService.java @@ -0,0 +1,71 @@ +package com.serdeliverance.cryptowallet.services; + +import com.serdeliverance.cryptowallet.domain.Cryptocurrency; +import com.serdeliverance.cryptowallet.domain.Transaction; +import com.serdeliverance.cryptowallet.dto.CurrencyQuoteDTO; +import com.serdeliverance.cryptowallet.dto.CurrencyTotalDTO; +import com.serdeliverance.cryptowallet.dto.PorfolioDTO; +import com.serdeliverance.cryptowallet.exceptions.InvalidOperationException; +import com.serdeliverance.cryptowallet.repositories.TransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import static com.serdeliverance.cryptowallet.dto.PorfolioDTO.emptyPortfolio; +import static java.util.stream.Collectors.groupingBy; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PortfolioService { + + private final UserService userService; + private final CryptocurrencyService cryptocurrencyService; + private final TransactionRepository transactionRepository; + + public PorfolioDTO getPortfolio(Integer userId) { + log.info("Getting portfolio for userId: {}", userId); + userService.validateUser(userId); + List transactions = transactionRepository.getByUser(userId); + return !transactions.isEmpty() ? buildPorfolio(userId, transactions) : emptyPortfolio(userId, LocalDateTime.now()); + } + + private PorfolioDTO buildPorfolio(Integer userId, List transactions) { + log.debug("Building crypto portfolio"); + Map quotesInUSD = cryptocurrencyService.quotes().stream() + .collect(Collectors.toMap(CurrencyQuoteDTO::getCrypto, crypto -> crypto.getQuoteInUsd())); + Map cryptoMap = cryptocurrencyService + .getByIdList(transactions.stream().map(tx -> tx.getCryptocurrencyId()).distinct().collect(Collectors.toList())) + .stream() + .collect(Collectors.toMap(Cryptocurrency::getId, crypto -> crypto.getName())); + List currencies = transactions.stream() + .collect(groupingBy(Transaction::getCryptocurrencyId)) + .entrySet().stream() + .map(entry -> + new CurrencyTotalDTO( + cryptoMap.get(entry.getKey()), + entry.getValue().stream() + .map(tx -> tx.getAmount()) + .reduce(BigDecimal.ZERO, BigDecimal::add))) + .collect(Collectors.toList()); + BigDecimal totalInUSD = currencies.stream() + .map(crypto -> crypto.getAmount().multiply(quotesInUSD.get(crypto.getCurrency()))).reduce(BigDecimal.ZERO, BigDecimal::add); + return new PorfolioDTO(userId, currencies, totalInUSD, LocalDateTime.now()); + } + + public void validateFunds(Integer userId, String cryptocurrency, BigDecimal amount) { + log.info("Validating funds for selling/transferring data. userId={}, cryptocurrency={}, amount={}", userId, cryptocurrency, amount); + BigDecimal currencyTotal = transactionRepository + .getByUser(userId) + .stream() + .filter(tx -> tx.getCryptocurrencyId().equals(cryptocurrencyService.getByName(cryptocurrency).getId())) + .map(Transaction::getAmount).reduce(BigDecimal.ZERO, BigDecimal::add); + if (currencyTotal.compareTo(amount) < 0) throw new InvalidOperationException("Insufficient funds for transference/selling"); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/services/TransactionService.java b/src/main/java/com/serdeliverance/cryptowallet/services/TransactionService.java new file mode 100644 index 0000000..cb8568f --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/services/TransactionService.java @@ -0,0 +1,75 @@ +package com.serdeliverance.cryptowallet.services; + +import com.serdeliverance.cryptowallet.domain.Cryptocurrency; +import com.serdeliverance.cryptowallet.domain.Transaction; +import com.serdeliverance.cryptowallet.dto.TransactionDTO; +import com.serdeliverance.cryptowallet.repositories.TransactionRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import static com.serdeliverance.cryptowallet.domain.OperationType.*; +import static java.util.Collections.EMPTY_LIST; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TransactionService { + + private final TransactionRepository transactionRepository; + private final CryptocurrencyService cryptocurrencyService; + private final UserService userService; + private final PortfolioService portfolioService; + + public List getHistory(Integer userId) { + log.info("Getting transaction history for userId: {}", userId); + userService.validateUser(userId); + List transactions = transactionRepository.getByUser(userId); + return transactions.isEmpty() ? EMPTY_LIST : buildHistory(transactions); + } + + private List buildHistory(List transactions) { + Map cryptoMap = cryptocurrencyService + .getByIdList(transactions.stream().map(Transaction::getCryptocurrencyId).distinct().collect(toList())) + .stream() + .collect(toMap(Cryptocurrency::getId, Cryptocurrency::getName)); + + return transactions.stream() + .map(tx -> new TransactionDTO(tx.getId(), cryptoMap.get(tx.getCryptocurrencyId()), tx.getAmount(), tx.getOperationType().name(), tx.getTransactionDate())) + .collect(toList()); + } + + public void transfer(Integer issuer, Integer receiver, String cryptocurrency, BigDecimal amount) { + log.info("Transferring {} {} from user: {} to user: {}", amount, cryptocurrency, issuer, receiver); + userService.validateUser(issuer); + userService.validateUser(receiver); + portfolioService.validateFunds(issuer, cryptocurrency, amount); + Integer cryptoCurrencyId = cryptocurrencyService.getByName(cryptocurrency).getId(); + transactionRepository.saveTransaction(issuer, cryptoCurrencyId, amount.multiply(BigDecimal.valueOf(-1)), TRANSFERENCE, LocalDateTime.now()); + transactionRepository.saveTransaction(receiver, cryptoCurrencyId, amount, BUY, LocalDateTime.now()); + } + + public void buy(Integer userId, String cryptocurrency, BigDecimal amountInUsd) { + log.info("Buying {} for an amount of {} dollars by user: {}", cryptocurrency, amountInUsd, userId); + userService.validateUser(userId); + Integer cryptoCurrencyId = cryptocurrencyService.getByName(cryptocurrency).getId(); + BigDecimal quote = cryptocurrencyService.getQuote(cryptocurrency).getQuoteInUsd(); + transactionRepository.saveTransaction(userId, cryptoCurrencyId, amountInUsd.divide(quote, 10, RoundingMode.CEILING), BUY, LocalDateTime.now()); + } + + public void sell(Integer userId, String cryptocurrency, BigDecimal amount) { + log.info("Selling {} {} by user: {}", amount, cryptocurrency, userId); + userService.validateUser(userId); + portfolioService.validateFunds(userId, cryptocurrency, amount); + transactionRepository.saveTransaction(userId, cryptocurrencyService.getByName(cryptocurrency).getId(), + amount.multiply(BigDecimal.valueOf(-1)), SELL, LocalDateTime.now()); + } +} diff --git a/src/main/java/com/serdeliverance/cryptowallet/services/UserService.java b/src/main/java/com/serdeliverance/cryptowallet/services/UserService.java new file mode 100644 index 0000000..8b2e672 --- /dev/null +++ b/src/main/java/com/serdeliverance/cryptowallet/services/UserService.java @@ -0,0 +1,56 @@ +package com.serdeliverance.cryptowallet.services; + +import com.serdeliverance.cryptowallet.domain.User; +import com.serdeliverance.cryptowallet.exceptions.ResourceNotFoundException; +import com.serdeliverance.cryptowallet.repositories.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UserService { + + private final UserRepository userRepository; + + public Optional get(Integer id) { + log.info("Getting user with id: {}", id); + return userRepository.find(id); + } + + public List getAll() { + log.info("Getting all users"); + return userRepository.getAll(); + } + + public void create(User user) { + log.info("Creating user"); + userRepository.save(user); + } + + public void update(User user) { + Integer userId = user.getId().get(); + if (this.exists(userId)) { + userRepository.update(user); + } else throw new ResourceNotFoundException("user: " + userId); + } + + public void delete(Integer userId) { + if (this.exists(userId)) { + userRepository.delete(userId); + } else throw new ResourceNotFoundException("user: " + userId); + } + + public void validateUser(Integer userId) { + log.info("Validating user {}", userId); + get(userId).orElseThrow(() -> new ResourceNotFoundException("user not found")); + } + + private boolean exists(Integer id) { + return this.get(id).isPresent(); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 8b13789..5fae0d5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,7 @@ +spring.datasource.driver-class-name=org.postgresql.Driver +spring.datasource.url=jdbc:postgresql://127.0.0.1:45432/cryptodb +spring.datasource.username=root +spring.datasource.password=root +coinmarketcap.api-key=${COINMARKETCAP_API_KEY} +coinmarketcap.url=${COINMARKETCAP_URL} \ No newline at end of file diff --git a/src/test/java/com/serdeliverance/cryptowallet/CryptoWalletApplicationTests.java b/src/test/java/com/serdeliverance/cryptowallet/CryptoWalletApplicationTests.java deleted file mode 100644 index 026415f..0000000 --- a/src/test/java/com/serdeliverance/cryptowallet/CryptoWalletApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.serdeliverance.cryptowallet; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class CryptoWalletApplicationTests { - - @Test - void contextLoads() { - } - -} diff --git a/src/test/java/com/serdeliverance/cryptowallet/services/PortfolioServiceTest.java b/src/test/java/com/serdeliverance/cryptowallet/services/PortfolioServiceTest.java new file mode 100644 index 0000000..ca174f3 --- /dev/null +++ b/src/test/java/com/serdeliverance/cryptowallet/services/PortfolioServiceTest.java @@ -0,0 +1,186 @@ +package com.serdeliverance.cryptowallet.services; + +import com.serdeliverance.cryptowallet.domain.Cryptocurrency; +import com.serdeliverance.cryptowallet.domain.Transaction; +import com.serdeliverance.cryptowallet.dto.CurrencyQuoteDTO; +import com.serdeliverance.cryptowallet.dto.CurrencyTotalDTO; +import com.serdeliverance.cryptowallet.dto.PorfolioDTO; +import com.serdeliverance.cryptowallet.exceptions.InvalidOperationException; +import com.serdeliverance.cryptowallet.exceptions.ResourceNotFoundException; +import com.serdeliverance.cryptowallet.repositories.TransactionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; + +import static com.serdeliverance.cryptowallet.domain.OperationType.BUY; +import static java.util.Arrays.asList; +import static java.util.Collections.EMPTY_LIST; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class PortfolioServiceTest { + + @Mock + private UserService userService; + + @Mock + private CryptocurrencyService cryptocurrencyService; + + @Mock + private TransactionRepository transactionRepository; + + private PortfolioService portfolioService; + + @BeforeEach + void setup() { + portfolioService = new PortfolioService(userService, cryptocurrencyService, transactionRepository); + } + + @Test + void whenHasNoTransactionItShouldReturnEmptyPorfolio() { + // given + Integer userId = 1; + + when(transactionRepository.getByUser(userId)).thenReturn(EMPTY_LIST); + + // when + PorfolioDTO result = portfolioService.getPortfolio(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getTotalInUSD()).isEqualTo(BigDecimal.ZERO); + assertThat(result.getCurrencies()).isEqualTo(EMPTY_LIST); + } + + @Test + void whenUserHasTransactionsWithOneCurrencyItShouldReturnPorfolio() { + // given + Integer userId = 1; + + when(transactionRepository.getByUser(userId)).thenReturn(asList( + new Transaction(23L, 1, 1, BigDecimal.valueOf(2), BUY, "2021-02-05T19:29:03.239"), + new Transaction(26L, 1, 1, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239")) + ); + when(cryptocurrencyService.quotes()).thenReturn(asList( + new CurrencyQuoteDTO("Bitcoin", BigDecimal.valueOf(48081.979491230726)), + new CurrencyQuoteDTO("Ethereum", BigDecimal.valueOf(1810.264184795378)), + new CurrencyQuoteDTO("Tether", BigDecimal.valueOf(1.00048112681234)), + new CurrencyQuoteDTO("Cardano", BigDecimal.valueOf(0.87957104579375))) + ); + when(cryptocurrencyService.getByIdList(asList(1))).thenReturn(singletonList(new Cryptocurrency(1, "Bitcoin", "BTC"))); + + // when + PorfolioDTO result = portfolioService.getPortfolio(userId); + + // then + BigDecimal expectedTotalInUsd = BigDecimal.valueOf(48081.979491230726).multiply(BigDecimal.valueOf(3)); + CurrencyTotalDTO expectedCurrency = new CurrencyTotalDTO("Bitcoin", BigDecimal.valueOf(3)); + + assertThat(result).isNotNull(); + assertThat(result.getUserId()).isEqualTo(userId); + assertThat(result.getCurrencies().get(0)).isEqualTo(expectedCurrency); + assertThat(result.getTotalInUSD()).isEqualTo(expectedTotalInUsd); + } + + @Test + void whenUserHasTransactionsWithMultipleCurrenciesItShouldReturnPorfolio() { + // given + Integer userId = 1; + + BigDecimal bitcoinQuote = BigDecimal.valueOf(48081.979491230726); + BigDecimal ethereumQuote = BigDecimal.valueOf(1810.264184795378); + BigDecimal tetherQuote = BigDecimal.valueOf(1.00048112681234); + + when(transactionRepository.getByUser(userId)).thenReturn(asList( + new Transaction(23L, 1, 1, BigDecimal.valueOf(2), BUY, "2021-02-05T19:29:03.239"), + new Transaction(26L, 1, 1, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239"), + new Transaction(27L, 1, 2, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239"), + new Transaction(34L, 1, 3, BigDecimal.valueOf(2), BUY, "2021-02-06T19:29:03.239"), + new Transaction(78L, 1, 2, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239")) + ); + when(cryptocurrencyService.quotes()).thenReturn(asList( + new CurrencyQuoteDTO("Bitcoin", bitcoinQuote), + new CurrencyQuoteDTO("Ethereum", ethereumQuote), + new CurrencyQuoteDTO("Tether", tetherQuote), + new CurrencyQuoteDTO("Cardano", BigDecimal.valueOf(0.87957104579375))) + ); + when(cryptocurrencyService.getByIdList(asList(1, 2, 3))).thenReturn(asList( + new Cryptocurrency(1, "Bitcoin", "BTC"), + new Cryptocurrency(2, "Ethereum", "ETH"), + new Cryptocurrency(3, "Tether", "TET")) + ); + + // when + PorfolioDTO result = portfolioService.getPortfolio(userId); + + CurrencyTotalDTO resultBitcoinTotal = result.getCurrencies().stream().filter(c -> c.getCurrency().equals("Bitcoin")).findFirst().get(); + CurrencyTotalDTO resultEthereumTotal = result.getCurrencies().stream().filter(c -> c.getCurrency().equals("Ethereum")).findFirst().get(); + CurrencyTotalDTO resultTetherTotal = result.getCurrencies().stream().filter(c -> c.getCurrency().equals("Tether")).findFirst().get(); + + // then + CurrencyTotalDTO expectedBitcoinTotal = new CurrencyTotalDTO("Bitcoin", BigDecimal.valueOf(3)); + CurrencyTotalDTO expectedEthereumTotal = new CurrencyTotalDTO("Ethereum", BigDecimal.valueOf(2)); + CurrencyTotalDTO expectedTetherTotal = new CurrencyTotalDTO("Tether", BigDecimal.valueOf(2)); + + BigDecimal expectedBitcoinInUsd = expectedBitcoinTotal.getAmount().multiply(bitcoinQuote); + BigDecimal expectedEthereumInUsd = expectedEthereumTotal.getAmount().multiply(ethereumQuote); + BigDecimal expectedTetherInUsd = expectedTetherTotal.getAmount().multiply(tetherQuote); + + BigDecimal expectedTotalInUsd = expectedBitcoinInUsd.add(expectedEthereumInUsd).add(expectedTetherInUsd); + + assertThat(result).isNotNull(); + assertThat(resultBitcoinTotal).isEqualTo(expectedBitcoinTotal); + assertThat(resultEthereumTotal).isEqualTo(expectedEthereumTotal); + assertThat(resultTetherTotal).isEqualTo(expectedTetherTotal); + assertThat(result.getTotalInUSD()).isEqualTo(expectedTotalInUsd); + } + + @Test + public void whenUserNotExistsItShouldThrowResourceNotFoundException() { + // given + Integer userId = 12; + + doThrow(ResourceNotFoundException.class).when(userService).validateUser(userId); + + // then + assertThrows(ResourceNotFoundException.class, () -> portfolioService.getPortfolio(userId)); + } + + @Test + public void whenUserHasNotEnoughFundsValidateShouldThrowException() { + // given + when(cryptocurrencyService.getByName("Bitcoin")).thenReturn(new Cryptocurrency(1, "Bitcoin", "BTC")); + when(transactionRepository.getByUser(2)) + .thenReturn(asList( + new Transaction(23L, 2, 1, BigDecimal.ONE, BUY, "2021-02-05T19:29:03.239"), + new Transaction(26L, 2, 1, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239"), + new Transaction(27L, 2, 1, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239")) + ); + + // when/then + assertThrows(InvalidOperationException.class, () -> portfolioService.validateFunds(2, "Bitcoin", BigDecimal.valueOf(10))); + } + + @Test + public void whenUserHasFundsItShouldReturnVoid() { + // given + when(cryptocurrencyService.getByName("Bitcoin")).thenReturn(new Cryptocurrency(1, "Bitcoin", "BTC")); + when(transactionRepository.getByUser(2)) + .thenReturn(asList( + new Transaction(23L, 2, 1, BigDecimal.ONE, BUY, "2021-02-05T19:29:03.239"), + new Transaction(26L, 2, 1, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239"), + new Transaction(27L, 2, 1, BigDecimal.ONE, BUY, "2021-02-06T19:29:03.239")) + ); + + // when/then + portfolioService.validateFunds(2, "Bitcoin", BigDecimal.ONE); + } +} diff --git a/src/test/java/com/serdeliverance/cryptowallet/services/TransactionServiceTest.java b/src/test/java/com/serdeliverance/cryptowallet/services/TransactionServiceTest.java new file mode 100644 index 0000000..c0879f4 --- /dev/null +++ b/src/test/java/com/serdeliverance/cryptowallet/services/TransactionServiceTest.java @@ -0,0 +1,150 @@ +package com.serdeliverance.cryptowallet.services; + +import com.serdeliverance.cryptowallet.domain.Cryptocurrency; +import com.serdeliverance.cryptowallet.domain.Transaction; +import com.serdeliverance.cryptowallet.dto.TransactionDTO; +import com.serdeliverance.cryptowallet.exceptions.InvalidOperationException; +import com.serdeliverance.cryptowallet.exceptions.ResourceNotFoundException; +import com.serdeliverance.cryptowallet.repositories.TransactionRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.util.List; + +import static com.serdeliverance.cryptowallet.domain.OperationType.BUY; +import static com.serdeliverance.cryptowallet.domain.OperationType.SELL; +import static java.util.Arrays.asList; +import static java.util.Collections.EMPTY_LIST; +import static java.util.Collections.singletonList; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +public class TransactionServiceTest { + + @Mock + private UserService userService; + + @Mock + private TransactionRepository transactionRepository; + + @Mock + private CryptocurrencyService cryptocurrencyService; + + @Mock + private PortfolioService portfolioService; + + private TransactionService transactionService; + + @BeforeEach + void setup() { + transactionService = new TransactionService(transactionRepository, cryptocurrencyService, userService, portfolioService); + } + + @Test + public void whenUserNotExistsItShouldThrowResourceNotFoundException() { + // given + Integer userId = 12; + + doThrow(ResourceNotFoundException.class).when(userService).validateUser(userId); + + // then + assertThrows(ResourceNotFoundException.class, () -> transactionService.getHistory(userId)); + + } + + @Test + public void whenUserHasNoTransactionItShouldReturnEmptyList() { + // given + Integer userId = 2; + + when(transactionRepository.getByUser(userId)).thenReturn(EMPTY_LIST); + + // when + List result = transactionService.getHistory(userId); + + // then + assertThat(result).isEmpty(); + } + + @Test + public void whenUserHasOneTransactionItShouldReturnTransactionHistory() { + // given + Integer userId = 1; + + when(transactionRepository.getByUser(userId)).thenReturn(singletonList( + new Transaction(12L, 1, 1, BigDecimal.valueOf(2), BUY, "2021-02-05T19:28:43.111") + )); + + when(cryptocurrencyService.getByIdList(singletonList(1))).thenReturn(asList( + new Cryptocurrency(1, "Bitcoin", "BTC")) + ); + + // when + List result = transactionService.getHistory(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.size()).isEqualTo(1); + assertThat(result.get(0).getCryptocurrency()).isEqualTo("Bitcoin"); + } + + @Test + public void whenUserTransactionsItShouldReturnTransactionHistory() { + // given + Integer userId = 1; + + when(transactionRepository.getByUser(userId)).thenReturn(asList( + new Transaction(12L, 1, 1, BigDecimal.valueOf(2), BUY, "2021-02-05T19:28:43.111"), + new Transaction(13L, 1, 1, BigDecimal.valueOf(1), SELL, "2021-02-05T19:28:43.111"), + new Transaction(14L, 1, 2, BigDecimal.valueOf(1), BUY, "2021-02-05T19:28:43.111") + )); + + when(cryptocurrencyService.getByIdList(asList(1, 2))).thenReturn(asList( + new Cryptocurrency(1, "Bitcoin", "BTC"), + new Cryptocurrency(2, "Ethereum", "ETH")) + ); + + // when + List result = transactionService.getHistory(userId); + + // then + assertThat(result).isNotNull(); + assertThat(result.size()).isEqualTo(3); + } + + @Test + public void whenTransferWithInvalidUserItShouldThrowInvalidOperationException() { + // given + doNothing().when(userService).validateUser(12); + doThrow(ResourceNotFoundException.class).when(userService).validateUser(2); + + // when/then + assertThrows(ResourceNotFoundException.class, () -> + transactionService.transfer(12, 2, "Bitcoin", BigDecimal.ONE)); + } + + @Test + public void whenUserAmountIsInvalidItShouldThrowInvalidaOperationException() { + // given + doThrow(InvalidOperationException.class).when(portfolioService).validateFunds(12, "Bitcoin", BigDecimal.ONE); + + // when/then + assertThrows(InvalidOperationException.class, () -> + transactionService.transfer(12, 2, "Bitcoin", BigDecimal.ONE)); + } + + @Test + public void whenUserTransferItShouldTransferOk() { + // given + when(cryptocurrencyService.getByName("Bitcoin")).thenReturn(new Cryptocurrency(1, "Bitcoin", "BTC")); + + // when/then + transactionService.transfer(12, 2, "Bitcoin", BigDecimal.ONE); + } +}