commit
8b6669b296
@ -0,0 +1,24 @@ |
|||||||
|
module.exports = { |
||||||
|
parser: '@typescript-eslint/parser', |
||||||
|
parserOptions: { |
||||||
|
project: 'tsconfig.json', |
||||||
|
sourceType: 'module', |
||||||
|
}, |
||||||
|
plugins: ['@typescript-eslint/eslint-plugin'], |
||||||
|
extends: [ |
||||||
|
'plugin:@typescript-eslint/recommended', |
||||||
|
'plugin:prettier/recommended', |
||||||
|
], |
||||||
|
root: true, |
||||||
|
env: { |
||||||
|
node: true, |
||||||
|
jest: true, |
||||||
|
}, |
||||||
|
ignorePatterns: ['.eslintrc.js'], |
||||||
|
rules: { |
||||||
|
'@typescript-eslint/interface-name-prefix': 'off', |
||||||
|
'@typescript-eslint/explicit-function-return-type': 'off', |
||||||
|
'@typescript-eslint/explicit-module-boundary-types': 'off', |
||||||
|
'@typescript-eslint/no-explicit-any': 'off', |
||||||
|
}, |
||||||
|
}; |
@ -0,0 +1,35 @@ |
|||||||
|
# compiled output |
||||||
|
/dist |
||||||
|
/node_modules |
||||||
|
|
||||||
|
# Logs |
||||||
|
logs |
||||||
|
*.log |
||||||
|
npm-debug.log* |
||||||
|
pnpm-debug.log* |
||||||
|
yarn-debug.log* |
||||||
|
yarn-error.log* |
||||||
|
lerna-debug.log* |
||||||
|
|
||||||
|
# OS |
||||||
|
.DS_Store |
||||||
|
|
||||||
|
# Tests |
||||||
|
/coverage |
||||||
|
/.nyc_output |
||||||
|
|
||||||
|
# IDEs and editors |
||||||
|
/.idea |
||||||
|
.project |
||||||
|
.classpath |
||||||
|
.c9/ |
||||||
|
*.launch |
||||||
|
.settings/ |
||||||
|
*.sublime-workspace |
||||||
|
|
||||||
|
# IDE - VSCode |
||||||
|
.vscode/* |
||||||
|
!.vscode/settings.json |
||||||
|
!.vscode/tasks.json |
||||||
|
!.vscode/launch.json |
||||||
|
!.vscode/extensions.json |
@ -0,0 +1,4 @@ |
|||||||
|
{ |
||||||
|
"singleQuote": true, |
||||||
|
"trailingComma": "all" |
||||||
|
} |
@ -0,0 +1,113 @@ |
|||||||
|
## Introduction |
||||||
|
Explore this code and be ready to tell us what is good or/and bad |
||||||
|
|
||||||
|
|
||||||
|
## Installation |
||||||
|
- npm install |
||||||
|
- npm install -g ts-node |
||||||
|
- Insert database connection information into ormconfig.json file |
||||||
|
- Run “db-build” |
||||||
|
|
||||||
|
## Running the app |
||||||
|
|
||||||
|
```bash |
||||||
|
# development |
||||||
|
$ npm run start |
||||||
|
|
||||||
|
# watch mode |
||||||
|
$ npm run start:dev |
||||||
|
|
||||||
|
## Test |
||||||
|
|
||||||
|
```bash |
||||||
|
# unit tests |
||||||
|
$ npm run test |
||||||
|
|
||||||
|
# e2e tests |
||||||
|
$ npm run test:e2e |
||||||
|
|
||||||
|
# test coverage |
||||||
|
$ npm run test:cov |
||||||
|
``` |
||||||
|
|
||||||
|
# Task original |
||||||
|
|
||||||
|
Create a RESTful API with an endpoint for transaction commission calculation. The API must use JSON format for requests and responses. |
||||||
|
|
||||||
|
**Request (Transaction) examples** |
||||||
|
|
||||||
|
*1st example* |
||||||
|
|
||||||
|
``` |
||||||
|
{ |
||||||
|
"date": "2021-01-01", |
||||||
|
"amount": "100.00", |
||||||
|
"currency": "EUR", |
||||||
|
"client_id": 42 |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
*2nd example* |
||||||
|
|
||||||
|
``` |
||||||
|
{ |
||||||
|
"date": "2021-01-01", |
||||||
|
"amount": "200.40", |
||||||
|
"currency": "USD", |
||||||
|
"client_id": 42 |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
**Response (Commission) example** |
||||||
|
|
||||||
|
``` |
||||||
|
{ |
||||||
|
"amount": "0.05", |
||||||
|
"currency": "EUR" |
||||||
|
} |
||||||
|
``` |
||||||
|
|
||||||
|
Commission response must **always** be in Euros. Please use a currency rates API ([https://api.exchangerate.host/2021-01-01](https://api.exchangerate.host/2021-01-01)) for transactions in currency other than Euros. |
||||||
|
|
||||||
|
### Commission calculation rules |
||||||
|
|
||||||
|
The **lowest** commission shall be used if there are **multiple** rules matching. |
||||||
|
|
||||||
|
**Rule #1: Default pricing** |
||||||
|
|
||||||
|
By default the price for every transaction is `0.5%` but not less than `0.05€`. |
||||||
|
|
||||||
|
**Rule #2: Client with a discount** |
||||||
|
|
||||||
|
Transaction price for the client with ID of `42` is `0.05€` (*unless other rules set lower commission*). |
||||||
|
|
||||||
|
**Rule #3: High turnover discount** |
||||||
|
|
||||||
|
Client after reaching transaction turnover of `1000.00€` (per month) gets a discount and transaction commission is `0.03€` for the following transactions. |
||||||
|
|
||||||
|
See below an example in CSV format of rules applied to various transactions. |
||||||
|
|
||||||
|
```jsx |
||||||
|
client_id,date,amount,currency,commission_amount,commission_currency |
||||||
|
42,2021-01-02,2000.00,EUR,0.05,EUR |
||||||
|
1,2021-01-03,500.00,EUR,2.50,EUR |
||||||
|
1,2021-01-04,499.00,EUR,2.50,EUR |
||||||
|
1,2021-01-05,100.00,EUR,0.50,EUR |
||||||
|
1,2021-01-06,1.00,EUR,0.03,EUR |
||||||
|
1,2021-02-01,500.00,EUR,2.50,EUR |
||||||
|
``` |
||||||
|
|
||||||
|
### Testing |
||||||
|
|
||||||
|
Please write at least one unit and one integration test. |
||||||
|
|
||||||
|
### Remarks |
||||||
|
|
||||||
|
- You can use any language and any framework. We expect you to show knowledge of your chosen language's ecosystem (frameworks, 3rd party libraries, etc.) |
||||||
|
- Code must follow good practices (such as SOLID, design patterns, etc.) and be easily extendable in case we need to add additional commission calculation rules in the future |
||||||
|
- Please include `README.md` with instructions how to run your completed task. |
||||||
|
|
||||||
|
### Submitting the task |
||||||
|
|
||||||
|
- Make sure you don't mention `[REDACTED]` anywhere in the code or repository name. |
||||||
|
- Please include how long it took for you to do the task. |
@ -0,0 +1,4 @@ |
|||||||
|
{ |
||||||
|
"collection": "@nestjs/schematics", |
||||||
|
"sourceRoot": "src" |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
{ |
||||||
|
"type": "mysql", |
||||||
|
"host": "", |
||||||
|
"port": 3306, |
||||||
|
"username": "", |
||||||
|
"password": "", |
||||||
|
"database": "tryout_backend", |
||||||
|
"entities": ["dist/**/*.entity{.ts,.js}"], |
||||||
|
"migrations": ["src/migrations/*.ts"], |
||||||
|
"synchronize": false |
||||||
|
} |
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,82 @@ |
|||||||
|
{ |
||||||
|
"name": "transaction-commission-app", |
||||||
|
"version": "0.0.1", |
||||||
|
"description": "", |
||||||
|
"author": "", |
||||||
|
"private": true, |
||||||
|
"license": "UNLICENSED", |
||||||
|
"scripts": { |
||||||
|
"prebuild": "rimraf dist", |
||||||
|
"build": "nest build", |
||||||
|
"db-build": "ts-node ./node_modules/typeorm/cli.js migration:run", |
||||||
|
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", |
||||||
|
"start": "nest start", |
||||||
|
"start:dev": "nest start --watch", |
||||||
|
"start:debug": "nest start --debug --watch", |
||||||
|
"start:prod": "node dist/main", |
||||||
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", |
||||||
|
"test": "jest", |
||||||
|
"test:watch": "jest --watch", |
||||||
|
"test:cov": "jest --coverage", |
||||||
|
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", |
||||||
|
"test:e2e": "jest --config ./test/jest-e2e.json" |
||||||
|
}, |
||||||
|
"dependencies": { |
||||||
|
"@nestjs/axios": "0.0.7", |
||||||
|
"@nestjs/common": "8.0.0", |
||||||
|
"@nestjs/core": "8.0.0", |
||||||
|
"@nestjs/platform-express": "8.0.0", |
||||||
|
"@nestjs/typeorm": "8.0.3", |
||||||
|
"axios": "0.26.1", |
||||||
|
"chai-as-promised": "^7.1.1", |
||||||
|
"joi": "17.6.0", |
||||||
|
"mysql2": "2.3.3", |
||||||
|
"reflect-metadata": "0.1.13", |
||||||
|
"rimraf": "3.0.2", |
||||||
|
"rxjs": "7.5.5" |
||||||
|
}, |
||||||
|
"devDependencies": { |
||||||
|
"@nestjs/cli": "8.0.0", |
||||||
|
"@nestjs/schematics": "8.0.0", |
||||||
|
"@nestjs/testing": "8.4.2", |
||||||
|
"@types/express": "4.17.13", |
||||||
|
"@types/jest": "27.4.1", |
||||||
|
"@types/joi": "17.2.3", |
||||||
|
"@types/node": "16.0.0", |
||||||
|
"@types/supertest": "2.0.11", |
||||||
|
"@typescript-eslint/eslint-plugin": "5.0.0", |
||||||
|
"@typescript-eslint/parser": "5.0.0", |
||||||
|
"eslint": "8.0.1", |
||||||
|
"eslint-config-prettier": "8.3.0", |
||||||
|
"eslint-plugin-prettier": "4.0.0", |
||||||
|
"jest": "27.2.5", |
||||||
|
"prettier": "2.3.2", |
||||||
|
"source-map-support": "0.5.20", |
||||||
|
"supertest": "6.1.3", |
||||||
|
"ts-jest": "27.0.3", |
||||||
|
"ts-loader": "9.2.3", |
||||||
|
"ts-node": "^10.0.0", |
||||||
|
"tsconfig-paths": "3.10.1", |
||||||
|
"typescript": "4.3.5" |
||||||
|
}, |
||||||
|
"jest": { |
||||||
|
"moduleFileExtensions": [ |
||||||
|
"js", |
||||||
|
"json", |
||||||
|
"ts" |
||||||
|
], |
||||||
|
"rootDir": "src", |
||||||
|
"testRegex": ".*\\.spec\\.ts$", |
||||||
|
"transform": { |
||||||
|
"^.+\\.(t|j)s$": "ts-jest" |
||||||
|
}, |
||||||
|
"collectCoverageFrom": [ |
||||||
|
"**/*.(t|j)s" |
||||||
|
], |
||||||
|
"coverageDirectory": "../coverage", |
||||||
|
"testEnvironment": "node", |
||||||
|
"moduleNameMapper": { |
||||||
|
"^src/(.*)$": "<rootDir>/$1" |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
import { Test, TestingModule } from '@nestjs/testing'; |
||||||
|
import { AppController } from './app.controller'; |
||||||
|
import { AppService } from './app.service'; |
||||||
|
|
||||||
|
describe('AppController', () => { |
||||||
|
let appController: AppController; |
||||||
|
|
||||||
|
beforeEach(async () => { |
||||||
|
const app: TestingModule = await Test.createTestingModule({ |
||||||
|
controllers: [AppController], |
||||||
|
providers: [AppService], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
appController = app.get<AppController>(AppController); |
||||||
|
}); |
||||||
|
|
||||||
|
describe('root', () => { |
||||||
|
it('should return "Hello World!"', () => { |
||||||
|
expect(appController.getHello()).toBe('Hello World!'); |
||||||
|
}); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,12 @@ |
|||||||
|
import { Controller, Get } from '@nestjs/common'; |
||||||
|
import { AppService } from './app.service'; |
||||||
|
|
||||||
|
@Controller() |
||||||
|
export class AppController { |
||||||
|
constructor(private readonly appService: AppService) {} |
||||||
|
|
||||||
|
@Get() |
||||||
|
getHello(): string { |
||||||
|
return this.appService.getHello(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
import { Module } from '@nestjs/common'; |
||||||
|
import { AppController } from './app.controller'; |
||||||
|
import { AppService } from './app.service'; |
||||||
|
import { TransactionModule } from './transaction/transaction.module'; |
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm'; |
||||||
|
import { ExchangeRateModule } from './exchange-rate/exchange-rate.module'; |
||||||
|
|
||||||
|
@Module({ |
||||||
|
imports: [TransactionModule, TypeOrmModule.forRoot(), ExchangeRateModule], |
||||||
|
controllers: [AppController], |
||||||
|
providers: [AppService], |
||||||
|
}) |
||||||
|
export class AppModule {} |
@ -0,0 +1,8 @@ |
|||||||
|
import { Injectable } from '@nestjs/common'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class AppService { |
||||||
|
getHello(): string { |
||||||
|
return 'Hello World!'; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
export enum URL { |
||||||
|
exchangeRateConvertUrl = 'https://api.exchangerate.host/{date}', |
||||||
|
} |
||||||
|
|
||||||
|
export type ExchangeRateInput = { |
||||||
|
date: string; |
||||||
|
}; |
||||||
|
|
||||||
|
export type ExchangeRateResponse = { |
||||||
|
motd: { |
||||||
|
msg: string; |
||||||
|
url: string; |
||||||
|
}; |
||||||
|
success: true; |
||||||
|
query: { |
||||||
|
from: string; |
||||||
|
to: string; |
||||||
|
amount: number; |
||||||
|
}; |
||||||
|
info: { |
||||||
|
rate: number; |
||||||
|
}; |
||||||
|
historical: boolean; |
||||||
|
date: string; |
||||||
|
result: number; |
||||||
|
}; |
@ -0,0 +1,10 @@ |
|||||||
|
import { Module } from '@nestjs/common'; |
||||||
|
import { ExchangeRateService } from './exchange-rate.service'; |
||||||
|
import { HttpModule } from '@nestjs/axios'; |
||||||
|
|
||||||
|
@Module({ |
||||||
|
imports: [HttpModule], |
||||||
|
providers: [ExchangeRateService], |
||||||
|
exports: [ExchangeRateService], |
||||||
|
}) |
||||||
|
export class ExchangeRateModule {} |
@ -0,0 +1,24 @@ |
|||||||
|
import { HttpService } from '@nestjs/axios'; |
||||||
|
import { Injectable } from '@nestjs/common'; |
||||||
|
import { AxiosResponse } from 'axios'; |
||||||
|
import { map, Observable } from 'rxjs'; |
||||||
|
import { |
||||||
|
ExchangeRateInput, |
||||||
|
ExchangeRateResponse, |
||||||
|
URL, |
||||||
|
} from './exchange-rate.dto'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class ExchangeRateService { |
||||||
|
constructor(private httpService: HttpService) {} |
||||||
|
|
||||||
|
convertCurrency(input: ExchangeRateInput): Observable<ExchangeRateResponse> { |
||||||
|
return this.httpService |
||||||
|
.get(URL.exchangeRateConvertUrl.replace('{date}', input.date)) |
||||||
|
.pipe( |
||||||
|
map((axiosResponse: AxiosResponse) => { |
||||||
|
return axiosResponse.data?.rates; |
||||||
|
}), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
import { NestFactory } from '@nestjs/core'; |
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express'; |
||||||
|
import { AppModule } from './app.module'; |
||||||
|
|
||||||
|
async function bootstrap() { |
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule); |
||||||
|
await app.listen(3000); |
||||||
|
} |
||||||
|
bootstrap(); |
@ -0,0 +1,10 @@ |
|||||||
|
import { Injectable, NestMiddleware } from '@nestjs/common'; |
||||||
|
import { Request, Response, NextFunction } from 'express'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class LoggerMiddleware implements NestMiddleware { |
||||||
|
use(req: Request, res: Response, next: NextFunction) { |
||||||
|
console.log(`[LOG] - Request: ${JSON.stringify(req.body)}`); |
||||||
|
next(); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
import {MigrationInterface, QueryRunner} from "typeorm"; |
||||||
|
|
||||||
|
export class Initial1649937605127 implements MigrationInterface { |
||||||
|
name = 'Initial1649937605127' |
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> { |
||||||
|
await queryRunner.query(`CREATE TABLE \`transaction\` (\`id\` int NOT NULL AUTO_INCREMENT, \`amount\` int NOT NULL, \`currency\` varchar(255) NOT NULL, \`client_id\` int NOT NULL, \`date\` varchar(255) NOT NULL, \`commission\` int NOT NULL, \`base_currency\` varchar(255) NOT NULL, \`base_amount\` int NOT NULL, PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); |
||||||
|
} |
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> { |
||||||
|
await queryRunner.query(`DROP TABLE \`transaction\``); |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,15 @@ |
|||||||
|
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common'; |
||||||
|
import { ObjectSchema } from 'joi'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class BodyValidationPipe implements PipeTransform { |
||||||
|
constructor(private schema: ObjectSchema) {} |
||||||
|
|
||||||
|
transform(value: any) { |
||||||
|
const { error } = this.schema.validate(value); |
||||||
|
if (error) { |
||||||
|
throw new BadRequestException(error.message); |
||||||
|
} |
||||||
|
return value; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,13 @@ |
|||||||
|
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class JsonValidationPipe implements PipeTransform { |
||||||
|
transform(value: any) { |
||||||
|
try { |
||||||
|
JSON.parse(value); |
||||||
|
} catch (error) { |
||||||
|
throw new BadRequestException('Request body should be in JSON format.'); |
||||||
|
} |
||||||
|
return value; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,101 @@ |
|||||||
|
import { Test, TestingModule } from '@nestjs/testing'; |
||||||
|
import chai from 'chai'; |
||||||
|
import chaiAsPromised from 'chai-as-promised'; |
||||||
|
import { TransactionController } from './transaction.controller'; |
||||||
|
import { ExchangeRateService } from '../exchange-rate/exchange-rate.service'; |
||||||
|
import { TransactionService } from './transaction.service'; |
||||||
|
import { TransactionInput } from './transaction.dto'; |
||||||
|
|
||||||
|
const { expect } = chai; |
||||||
|
chai.use(chaiAsPromised); |
||||||
|
|
||||||
|
describe('TransactionController Unit Tests', () => { |
||||||
|
let transactionController: TransactionController; |
||||||
|
|
||||||
|
beforeAll(async () => { |
||||||
|
const TransactionServiceProvider = { |
||||||
|
provide: TransactionService, |
||||||
|
useFactory: () => ({ |
||||||
|
insertOne: jest.fn(() => Promise.reject(new Error("DatabaseNotReachable"))), |
||||||
|
findByClientIdWithinActualMonth: jest.fn((clientId) => { |
||||||
|
if (clientId === 42) { |
||||||
|
return [{base_amount: 1500}]; |
||||||
|
} |
||||||
|
return []; |
||||||
|
}), |
||||||
|
}), |
||||||
|
}; |
||||||
|
|
||||||
|
const ExchangeRateServiceProviderForUsd = { |
||||||
|
provide: ExchangeRateService, |
||||||
|
useFactory: () => ({ |
||||||
|
convertCurrency: jest.fn(({ amount }) => ({ |
||||||
|
subscribe: ({ next }) => next({ USD: 1.2 }), |
||||||
|
})), |
||||||
|
}), |
||||||
|
}; |
||||||
|
|
||||||
|
const app: TestingModule = await Test.createTestingModule({ |
||||||
|
controllers: [TransactionController], |
||||||
|
providers: [ |
||||||
|
TransactionService, |
||||||
|
TransactionServiceProvider, |
||||||
|
ExchangeRateServiceProviderForUsd, |
||||||
|
], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
transactionController = app.get<TransactionController>( |
||||||
|
TransactionController, |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('calling commission method correct result should be got', async () => { |
||||||
|
const mockTransactionInput: TransactionInput = { |
||||||
|
date: '2021-01-05', |
||||||
|
amount: '1000.00', |
||||||
|
currency: 'EUR', |
||||||
|
client_id: 1, |
||||||
|
}; |
||||||
|
|
||||||
|
const result = await transactionController.commission(mockTransactionInput); |
||||||
|
|
||||||
|
expect(result).to.eql( |
||||||
|
JSON.stringify({ |
||||||
|
amount: parseFloat('5.00').toFixed(2), |
||||||
|
currency: 'EUR', |
||||||
|
}), |
||||||
|
); |
||||||
|
}); |
||||||
|
|
||||||
|
it('calling applyRules method minimum result of applied rules should be got', async () => { |
||||||
|
const mockTransactionInput: TransactionInput = { |
||||||
|
date: '2021-01-05', |
||||||
|
amount: '1000.00', |
||||||
|
currency: 'EUR', |
||||||
|
client_id: 1, |
||||||
|
}; |
||||||
|
|
||||||
|
const result = await transactionController.applyRules( |
||||||
|
[transactionController.discountRule, transactionController.turnoverRule], |
||||||
|
mockTransactionInput, |
||||||
|
); |
||||||
|
|
||||||
|
expect(result).to.eql(5); |
||||||
|
}); |
||||||
|
|
||||||
|
it('calling commission method correct commission should be got', async () => { |
||||||
|
const mockTransactionInput: TransactionInput = { |
||||||
|
date: '2021-01-05', |
||||||
|
amount: '1000.00', |
||||||
|
currency: 'USD', |
||||||
|
client_id: 1, |
||||||
|
}; |
||||||
|
|
||||||
|
const result = await transactionController.commission( |
||||||
|
mockTransactionInput, |
||||||
|
); |
||||||
|
|
||||||
|
expect(result).to.eql("{\"amount\":\"4.17\",\"currency\":\"EUR\"}"); |
||||||
|
}); |
||||||
|
|
||||||
|
}); |
@ -0,0 +1,164 @@ |
|||||||
|
import { Controller, Post, UsePipes, Body } from '@nestjs/common'; |
||||||
|
import { ExchangeRateService } from 'src/exchange-rate/exchange-rate.service'; |
||||||
|
import { TransactionService } from './transaction.service'; |
||||||
|
import { transactionBodySchema } from './transaction.validation'; |
||||||
|
import { BodyValidationPipe } from '../pipes/body.validation.pipe'; |
||||||
|
import { |
||||||
|
Currency, |
||||||
|
TransactionInput, |
||||||
|
DiscountRuleForClientById, |
||||||
|
DefaultCommissionPercentage, |
||||||
|
DefaultCommissionAmount, |
||||||
|
HighTurnoverDiscount, |
||||||
|
} from './transaction.dto'; |
||||||
|
|
||||||
|
@Controller('transaction') |
||||||
|
export class TransactionController { |
||||||
|
constructor( |
||||||
|
private readonly transactionService: TransactionService, |
||||||
|
private readonly exchangeRateService: ExchangeRateService, |
||||||
|
) {} |
||||||
|
|
||||||
|
@Post() |
||||||
|
@UsePipes(new BodyValidationPipe(transactionBodySchema)) |
||||||
|
async commission( |
||||||
|
@Body() transactionInput: TransactionInput, |
||||||
|
): Promise<string> { |
||||||
|
return JSON.stringify({ |
||||||
|
amount: |
||||||
|
transactionInput.currency !== Currency.EUR |
||||||
|
? parseFloat( |
||||||
|
await this.getAmountWithExchange(transactionInput), |
||||||
|
).toFixed(2) |
||||||
|
: parseFloat( |
||||||
|
await this.getAmountWithoutExchange(transactionInput), |
||||||
|
).toFixed(2), |
||||||
|
currency: Currency.EUR, |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
getClientDeposit = async (transactionInput: TransactionInput) => { |
||||||
|
try { |
||||||
|
const deposit = |
||||||
|
await this.transactionService.findByClientIdWithinActualMonth( |
||||||
|
transactionInput.client_id, |
||||||
|
); |
||||||
|
|
||||||
|
if (deposit) { |
||||||
|
const initialDeposit = 0; |
||||||
|
const totalDeposit = (await deposit).reduce( |
||||||
|
(prevAmmount, transactionAmmount) => |
||||||
|
prevAmmount + transactionAmmount.base_amount, |
||||||
|
initialDeposit, |
||||||
|
); |
||||||
|
|
||||||
|
return totalDeposit; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.log(error); |
||||||
|
} |
||||||
|
return 0; |
||||||
|
}; |
||||||
|
|
||||||
|
turnoverRule = async (transactionInput: TransactionInput) => { |
||||||
|
try { |
||||||
|
const clientDeposit = await this.getClientDeposit(transactionInput); |
||||||
|
if (clientDeposit) { |
||||||
|
return clientDeposit > 1000 ? HighTurnoverDiscount.amount : false; |
||||||
|
} |
||||||
|
} catch (error) { |
||||||
|
console.log(error); |
||||||
|
} |
||||||
|
}; |
||||||
|
|
||||||
|
discountRule(transactionInput: TransactionInput) { |
||||||
|
return transactionInput.client_id === 42 |
||||||
|
? DiscountRuleForClientById.client_42 |
||||||
|
: false; |
||||||
|
} |
||||||
|
|
||||||
|
defaultRule(transactionInput: TransactionInput) { |
||||||
|
const commissionAmount = |
||||||
|
(parseInt(transactionInput.amount) / 100) * |
||||||
|
DefaultCommissionPercentage.percentage; |
||||||
|
return commissionAmount < DefaultCommissionAmount.amount |
||||||
|
? DefaultCommissionAmount.amount |
||||||
|
: commissionAmount; |
||||||
|
} |
||||||
|
|
||||||
|
async applyRules( |
||||||
|
rules: ((transactionInput: TransactionInput) => any)[], |
||||||
|
transactionInput: TransactionInput, |
||||||
|
) { |
||||||
|
let commissionAmount; |
||||||
|
for (let i = 0; i < rules.length; i++) { |
||||||
|
const ruleResult = await rules[i](transactionInput); |
||||||
|
if (ruleResult) { |
||||||
|
commissionAmount = ruleResult; |
||||||
|
break; |
||||||
|
} else { |
||||||
|
continue; |
||||||
|
} |
||||||
|
} |
||||||
|
return commissionAmount |
||||||
|
? commissionAmount |
||||||
|
: this.defaultRule(transactionInput); |
||||||
|
} |
||||||
|
|
||||||
|
getAmountWithExchange(transactionInput: TransactionInput) { |
||||||
|
const commissionAmount = this.applyRules( |
||||||
|
[this.turnoverRule, this.discountRule], |
||||||
|
transactionInput, |
||||||
|
); |
||||||
|
|
||||||
|
const exhangeRateInput = { |
||||||
|
date: transactionInput.date, |
||||||
|
}; |
||||||
|
|
||||||
|
try { |
||||||
|
this.exchangeRateService.convertCurrency(exhangeRateInput).subscribe({ |
||||||
|
next: async (exchangeRateResponse) => { |
||||||
|
this.transactionService.insertOne({ |
||||||
|
date: transactionInput.date, |
||||||
|
amount: parseInt(transactionInput.amount), |
||||||
|
currency: transactionInput.currency, |
||||||
|
client_id: transactionInput.client_id, |
||||||
|
commission: await commissionAmount, |
||||||
|
base_currency: Currency.EUR, |
||||||
|
base_amount: parseInt(transactionInput.amount) * exchangeRateResponse[transactionInput.currency], |
||||||
|
}); |
||||||
|
}, |
||||||
|
error: (error) => { |
||||||
|
console.log(error); |
||||||
|
}, |
||||||
|
}); |
||||||
|
} catch (error) { |
||||||
|
console.log(error); |
||||||
|
} |
||||||
|
|
||||||
|
return commissionAmount; |
||||||
|
} |
||||||
|
|
||||||
|
async getAmountWithoutExchange(transactionInput: TransactionInput) { |
||||||
|
const commissionAmount = await this.applyRules( |
||||||
|
[this.turnoverRule, this.discountRule], |
||||||
|
transactionInput, |
||||||
|
); |
||||||
|
try { |
||||||
|
this.transactionService.insertOne({ |
||||||
|
date: transactionInput.date, |
||||||
|
amount: parseInt(transactionInput.amount), |
||||||
|
currency: transactionInput.currency, |
||||||
|
client_id: transactionInput.client_id, |
||||||
|
commission: commissionAmount, |
||||||
|
base_currency: Currency.EUR, |
||||||
|
base_amount: parseInt(transactionInput.amount), |
||||||
|
}); |
||||||
|
} catch (error) { |
||||||
|
console.log(error); |
||||||
|
throw error; |
||||||
|
} |
||||||
|
|
||||||
|
return commissionAmount; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,26 @@ |
|||||||
|
export class TransactionInput { |
||||||
|
date: string; |
||||||
|
amount: string; |
||||||
|
currency: string; |
||||||
|
client_id: number; |
||||||
|
} |
||||||
|
|
||||||
|
export enum Currency { |
||||||
|
EUR = 'EUR', |
||||||
|
} |
||||||
|
|
||||||
|
export enum DiscountRuleForClientById { |
||||||
|
client_42 = 0.05, |
||||||
|
} |
||||||
|
|
||||||
|
export enum DefaultCommissionPercentage { |
||||||
|
percentage = 0.5, |
||||||
|
} |
||||||
|
|
||||||
|
export enum DefaultCommissionAmount { |
||||||
|
amount = 0.05, |
||||||
|
} |
||||||
|
|
||||||
|
export enum HighTurnoverDiscount { |
||||||
|
amount = 0.03, |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; |
||||||
|
|
||||||
|
@Entity() |
||||||
|
export class Transaction { |
||||||
|
@PrimaryGeneratedColumn() |
||||||
|
id?: number; |
||||||
|
|
||||||
|
@Column() |
||||||
|
amount: number; |
||||||
|
|
||||||
|
@Column() |
||||||
|
currency: string; |
||||||
|
|
||||||
|
@Column() |
||||||
|
client_id: number; |
||||||
|
|
||||||
|
@Column() |
||||||
|
date: string; |
||||||
|
|
||||||
|
@Column() |
||||||
|
commission: number; |
||||||
|
|
||||||
|
@Column() |
||||||
|
base_currency: string; |
||||||
|
|
||||||
|
@Column() |
||||||
|
base_amount: number; |
||||||
|
} |
@ -0,0 +1,25 @@ |
|||||||
|
import { |
||||||
|
Module, |
||||||
|
NestModule, |
||||||
|
RequestMethod, |
||||||
|
MiddlewareConsumer, |
||||||
|
} from '@nestjs/common'; |
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm'; |
||||||
|
import { TransactionController } from './transaction.controller'; |
||||||
|
import { TransactionService } from './transaction.service'; |
||||||
|
import { LoggerMiddleware } from 'src/middlewares/logger.middleware'; |
||||||
|
import { Transaction } from './transaction.entity'; |
||||||
|
import { ExchangeRateModule } from './../exchange-rate/exchange-rate.module'; |
||||||
|
|
||||||
|
@Module({ |
||||||
|
imports: [TypeOrmModule.forFeature([Transaction]), ExchangeRateModule], |
||||||
|
controllers: [TransactionController], |
||||||
|
providers: [TransactionService], |
||||||
|
}) |
||||||
|
export class TransactionModule implements NestModule { |
||||||
|
configure(consumer: MiddlewareConsumer) { |
||||||
|
consumer |
||||||
|
.apply(LoggerMiddleware) |
||||||
|
.forRoutes({ path: 'transaction', method: RequestMethod.POST }); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
import { Test, TestingModule } from '@nestjs/testing'; |
||||||
|
import { getRepositoryToken } from '@nestjs/typeorm'; |
||||||
|
import { Transaction } from './transaction.entity'; |
||||||
|
import { TransactionService } from './transaction.service'; |
||||||
|
|
||||||
|
describe('TransactionService', () => { |
||||||
|
let service: TransactionService; |
||||||
|
|
||||||
|
const mockEmployeesRepository = { |
||||||
|
save: jest.fn().mockImplementation((dto: Transaction) => { |
||||||
|
return Promise.resolve(dto); |
||||||
|
}), |
||||||
|
}; |
||||||
|
|
||||||
|
beforeEach(async () => { |
||||||
|
const module: TestingModule = await Test.createTestingModule({ |
||||||
|
providers: [ |
||||||
|
TransactionService, |
||||||
|
{ |
||||||
|
provide: getRepositoryToken(Transaction), |
||||||
|
useValue: mockEmployeesRepository, |
||||||
|
}, |
||||||
|
], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
service = module.get<TransactionService>(TransactionService); |
||||||
|
}); |
||||||
|
|
||||||
|
it('should be defined', () => { |
||||||
|
expect(service).toBeDefined(); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,23 @@ |
|||||||
|
import { Injectable } from '@nestjs/common'; |
||||||
|
import { InjectRepository } from '@nestjs/typeorm'; |
||||||
|
import { Repository } from 'typeorm'; |
||||||
|
import { Transaction } from './transaction.entity'; |
||||||
|
|
||||||
|
@Injectable() |
||||||
|
export class TransactionService { |
||||||
|
constructor( |
||||||
|
@InjectRepository(Transaction) |
||||||
|
private transactionRepository: Repository<Transaction>, |
||||||
|
) {} |
||||||
|
|
||||||
|
async insertOne(transaction: Transaction): Promise<Transaction> { |
||||||
|
return this.transactionRepository.save(transaction); |
||||||
|
} |
||||||
|
|
||||||
|
async findByClientIdWithinActualMonth(clientId): Promise<Transaction[]> { |
||||||
|
return this.transactionRepository.query( |
||||||
|
`SELECT amount,commission,currency,client_id,base_amount FROM tryout_backend.transaction WHERE client_id = ${clientId} and
|
||||||
|
MONTH(date) = MONTH(CURRENT_DATE())AND YEAR(date) = YEAR(CURRENT_DATE())`,
|
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
import * as Joi from 'joi'; |
||||||
|
|
||||||
|
const transactionBodySchema = Joi.object({ |
||||||
|
date: Joi.date().required(), |
||||||
|
amount: Joi.number().required(), |
||||||
|
currency: Joi.string().length(3).required(), |
||||||
|
client_id: Joi.number().required(), |
||||||
|
}); |
||||||
|
|
||||||
|
export { transactionBodySchema }; |
@ -0,0 +1,24 @@ |
|||||||
|
import { Test, TestingModule } from '@nestjs/testing'; |
||||||
|
import { INestApplication } from '@nestjs/common'; |
||||||
|
import request from 'supertest'; |
||||||
|
import { AppModule } from './../src/app.module'; |
||||||
|
|
||||||
|
describe('AppController (e2e)', () => { |
||||||
|
let app: INestApplication; |
||||||
|
|
||||||
|
beforeEach(async () => { |
||||||
|
const moduleFixture: TestingModule = await Test.createTestingModule({ |
||||||
|
imports: [AppModule], |
||||||
|
}).compile(); |
||||||
|
|
||||||
|
app = moduleFixture.createNestApplication(); |
||||||
|
await app.init(); |
||||||
|
}); |
||||||
|
|
||||||
|
it('/ (GET)', () => { |
||||||
|
return request(app.getHttpServer()) |
||||||
|
.get('/') |
||||||
|
.expect(200) |
||||||
|
.expect('Hello World!'); |
||||||
|
}); |
||||||
|
}); |
@ -0,0 +1,9 @@ |
|||||||
|
{ |
||||||
|
"moduleFileExtensions": ["js", "json", "ts"], |
||||||
|
"rootDir": ".", |
||||||
|
"testEnvironment": "node", |
||||||
|
"testRegex": ".e2e-spec.ts$", |
||||||
|
"transform": { |
||||||
|
"^.+\\.(t|j)s$": "ts-jest" |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,4 @@ |
|||||||
|
{ |
||||||
|
"extends": "./tsconfig.json", |
||||||
|
"exclude": ["node_modules", "test", "dist", "**/*spec.ts"] |
||||||
|
} |
@ -0,0 +1,22 @@ |
|||||||
|
{ |
||||||
|
"compilerOptions": { |
||||||
|
"module": "commonjs", |
||||||
|
"declaration": true, |
||||||
|
"removeComments": true, |
||||||
|
"emitDecoratorMetadata": true, |
||||||
|
"experimentalDecorators": true, |
||||||
|
"allowSyntheticDefaultImports": true, |
||||||
|
"target": "es2017", |
||||||
|
"sourceMap": true, |
||||||
|
"outDir": "./dist", |
||||||
|
"baseUrl": "./", |
||||||
|
"incremental": true, |
||||||
|
"skipLibCheck": true, |
||||||
|
"strictNullChecks": false, |
||||||
|
"noImplicitAny": false, |
||||||
|
"strictBindCallApply": false, |
||||||
|
"forceConsistentCasingInFileNames": false, |
||||||
|
"noFallthroughCasesInSwitch": false, |
||||||
|
"esModuleInterop": true, |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue