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