Skip to content
This repository has been archived by the owner on May 11, 2021. It is now read-only.

Commit

Permalink
feat(coverter): add stability conversation and history rates
Browse files Browse the repository at this point in the history
  • Loading branch information
igorkamyshev committed Mar 2, 2019
1 parent 2878ce1 commit 4d45439
Show file tree
Hide file tree
Showing 6 changed files with 197 additions and 33 deletions.
41 changes: 41 additions & 0 deletions back/evolutions/3.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
DELETE FROM public.exchange_rate;

ALTER TABLE ONLY public.exchange_rate
RENAME "due" TO "collectAt";

ALTER TABLE ONLY public.exchange_rate
ALTER COLUMN "collectAt" TYPE timestamptz;

ALTER TABLE ONLY public.exchange_rate
DROP CONSTRAINT "PK_7d7c724e18481fe9a76491f9c89";

ALTER TABLE ONLY public.exchange_rate
ADD CONSTRAINT "PK_7d7c724e18481fe9a76491f9c89" PRIMARY KEY ("from", "to", "collectAt");

ALTER TABLE ONLY public.income
ALTER COLUMN date TYPE timestamptz;

ALTER TABLE ONLY public.outcome
ALTER COLUMN date TYPE timestamptz;

#DOWN

DELETE FROM public.exchange_rate;

ALTER TABLE ONLY public.exchange_rate
DROP CONSTRAINT "PK_7d7c724e18481fe9a76491f9c89";

ALTER TABLE ONLY public.exchange_rate
ADD CONSTRAINT "PK_7d7c724e18481fe9a76491f9c89" PRIMARY KEY ("from", "to");

ALTER TABLE ONLY public.exchange_rate
RENAME "collectAt" TO "due";

ALTER TABLE ONLY public.exchange_rate
ALTER COLUMN "due" TYPE timestamp without time zone;

ALTER TABLE ONLY public.income
ALTER COLUMN date TYPE timestamp without time zone;

ALTER TABLE ONLY public.outcome
ALTER COLUMN date TYPE timestamp without time zone;
67 changes: 52 additions & 15 deletions back/src/money/application/CurrencyConverter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'
import { addDays } from 'date-fns'
import { differenceInDays, startOfHour } from 'date-fns'

import { EntitySaver } from '@back/db/EntitySaver'
import { Currency } from '@shared/enum/Currency'
Expand All @@ -20,36 +20,73 @@ export class CurrencyConverter {
from: Currency,
to: Currency,
amount: number,
when: Date,
): Promise<number> {
if (from === to) {
return amount
}

const rate = await this.getExchangeRate(from, to)
const normalizedDate = startOfHour(when)

const rate = await this.getExchangeRate(from, to, normalizedDate).catch(
async e => {
// if correct rate getting failed, we can return the closest available rate
const closestRate = await this.exchangeRateRepo.findClosest(
from,
to,
normalizedDate,
)

if (closestRate.nonEmpty()) {
return closestRate.get().rate
}

throw e
},
)

return Math.round(amount * rate)
}

private async getExchangeRate(from: Currency, to: Currency): Promise<number> {
const existRate = await this.exchangeRateRepo.find(from, to)

const overdue = existRate.map(rate => rate.due < new Date()).getOrElse(true)
private async getExchangeRate(
from: Currency,
to: Currency,
when: Date,
): Promise<number> {
const existRate = await this.exchangeRateRepo.find(from, to, when)

if (existRate.nonEmpty() && !overdue) {
if (existRate.nonEmpty()) {
return existRate.get().rate
}

const actualRate = await this.exchangeRateApi.getExchangeRate(from, to)
const actualRate = await this.fetchExchangeRate(from, to, when)

const newRate = new ExchangeRate(
from,
to,
addDays(new Date(), 1),
actualRate,
)
const newRate = new ExchangeRate(from, to, when, actualRate)

await this.entitySaver.save(newRate)
await this.entitySaver.save(newRate).catch(() => {
// Okay, rate not saved
})

return newRate.rate
}

private async fetchExchangeRate(
from: Currency,
to: Currency,
when: Date,
): Promise<number> {
const MIN_DAY_FOR_HISTORY_TRANSACTION = 2

const rateIsHistory =
differenceInDays(when, new Date()) > MIN_DAY_FOR_HISTORY_TRANSACTION

const getNowRate = () => this.exchangeRateApi.getExchangeRate(from, to)

const getHistoryRate = () =>
this.exchangeRateApi
.getHistoryExchangeRate(from, to, when)
.catch(getNowRate) // Okay, now rate it ok

return rateIsHistory ? getNowRate() : getHistoryRate()
}
}
1 change: 1 addition & 0 deletions back/src/money/application/Statistician.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ export class Statistician {
item.currency,
targetCurrency,
item.amount,
item.date,
)

return new Transaction(newAmount, targetCurrency, item.date)
Expand Down
13 changes: 9 additions & 4 deletions back/src/money/domain/ExchangeRate.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,21 @@ export class ExchangeRate {
@PrimaryColumn({ type: 'enum', enum: Currency })
public readonly to: Currency

@Column()
public readonly due: Date
@PrimaryColumn()
public readonly collectAt: Date

@Column({ type: 'float' })
public readonly rate: number

public constructor(from: Currency, to: Currency, due: Date, rate: number) {
public constructor(
from: Currency,
to: Currency,
collectAt: Date,
rate: number,
) {
this.from = from
this.to = to
this.due = due
this.collectAt = collectAt
this.rate = rate
}
}
58 changes: 53 additions & 5 deletions back/src/money/domain/ExchangeRateRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'
import { InjectRepository } from '@nestjs/typeorm'
import { Option } from 'tsoption'
import { Repository } from 'typeorm'
import { startOfDay, endOfDay, differenceInMilliseconds } from 'date-fns'

import { Currency } from '@shared/enum/Currency'

Expand All @@ -17,14 +18,61 @@ class ExchangeRateRepo {
public async find(
from: Currency,
to: Currency,
forWhen: Date = new Date(),
): Promise<Option<ExchangeRate>> {
const rate = await this.exchageRateRepo
.createQueryBuilder('rate')
.where('rate.from = :from')
.andWhere('rate.to = :to')
.setParameters({ from, to })
const startDate = startOfDay(forWhen)
const endDate = endOfDay(forWhen)

return this.createFromToQueryBuilder('rate')
.andWhere('rate.collectAt >= :startDate')
.andWhere('rate.collectAt <= :endDate')
.setParameters({ from, to, startDate, endDate })
.getOne()
.then(this.toOption)
}

public async findClosest(
from: Currency,
to: Currency,
forWhen: Date = new Date(),
): Promise<Option<ExchangeRate>> {
const findBefore = () =>
this.createFromToQueryBuilder('rate')
.andWhere('rate.collectAt <= :forWhen')
.orderBy('rate.collectAt', 'DESC')
.setParameters({ from, to, forWhen })
.getOne()
.then(this.toOption)

const findAfter = () =>
this.createFromToQueryBuilder('rate')
.andWhere('rate.collectAt >= :forWhen')
.orderBy('rate.collectAt', 'DESC')
.setParameters({ from, to, forWhen })
.getOne()
.then(this.toOption)

const [before, after] = await Promise.all([findBefore(), findAfter()])

const rateToDiff = (rate: Option<ExchangeRate>) =>
rate
.map(r => differenceInMilliseconds(r.collectAt, forWhen))
.getOrElse(Infinity)

const beforeDiff = rateToDiff(before)
const afterDiff = rateToDiff(after)

return afterDiff < beforeDiff ? after : before
}

private createFromToQueryBuilder(alias: string) {
return this.exchageRateRepo
.createQueryBuilder(alias)
.where(`${alias}.from = :from`)
.andWhere(`${alias}.to = :to`)
}

private toOption(rate: ExchangeRate): Option<ExchangeRate> {
return Option.of(rate)
}
}
Expand Down
50 changes: 41 additions & 9 deletions back/src/money/insfrastructure/ExchangeRateApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,28 +3,30 @@ import Axios from 'axios'

import { Configuration } from '@back/config/Configuration'
import { Currency } from '@shared/enum/Currency'
import { format, differenceInDays, subDays } from 'date-fns'

interface PromiseCacheMap {
[key: string]: Promise<number>
}

const API_URL = 'https://free.currencyconverterapi.com'

@Injectable()
export class ExchangeRateApi {
private readonly apiKey: string

private readonly promises: PromiseCacheMap = {}
private readonly simplePromises: PromiseCacheMap = {}
private readonly historyPromises: PromiseCacheMap = {}

public constructor(config: Configuration) {
this.apiKey = config.getStringOrElse('MANNY_API_KEY', '')
}

public getExchangeRate(from: Currency, to: Currency): Promise<number> {
const API_URL = 'https://free.currencyconverterapi.com'

const query = this.createQuery(from, to)
const query = `${from}_${to}`

if (!this.promises[query]) {
this.promises[query] = Axios.get(
if (!this.simplePromises[query]) {
this.simplePromises[query] = Axios.get(
`${API_URL}/api/v6/convert?q=${query}&apiKey=${this.apiKey}`,
)
.then(response => response.data)
Expand All @@ -33,10 +35,40 @@ export class ExchangeRateApi {
.then(rate => parseFloat(rate.val))
}

return this.promises[query]
return this.simplePromises[query]
}

private createQuery(from: Currency, to: Currency): string {
return `${from}_${to}`
public getHistoryExchangeRate(
from: Currency,
to: Currency,
when: Date,
): Promise<number> {
const MAX_RATE_AGE_IN_DAYS = 360

const dateInvalidForApi =
differenceInDays(when, new Date()) >= MAX_RATE_AGE_IN_DAYS
const correctDate = dateInvalidForApi
? subDays(new Date(), MAX_RATE_AGE_IN_DAYS)
: when

const dateQuery = format(correctDate, 'YYYY-MM-DD')
const currencyQuery = `${from}_${to}`

const fullQuery = `${currencyQuery}_${dateQuery}`

if (!this.historyPromises[fullQuery]) {
this.historyPromises[fullQuery] = Axios.get(
`${API_URL}/api/v6/convert?q=${currencyQuery}&apiKey=${
this.apiKey
}&date=${dateQuery}`,
)
.then(response => response.data)
.then(data => data.results)
.then(results => results[currencyQuery])
.then(dateData => dateData[dateQuery])
.then(rate => parseFloat(rate.val))
}

return this.historyPromises[fullQuery]
}
}

0 comments on commit 4d45439

Please sign in to comment.