From 42705d56bb67f761a918a30c278f8ee694c6f122 Mon Sep 17 00:00:00 2001 From: Arran Fletcher Date: Mon, 10 Feb 2020 10:43:06 +0800 Subject: [PATCH] [app] implemented money service, fetching balance and sources --- .gitignore | 3 + config/default.json | 5 ++ package-lock.json | 5 ++ package.json | 1 + src/app.controller.ts | 35 +++++---- src/discord/core/balance.ts | 12 ++++ .../core/currency-decimal-places.enum.ts | 18 +++++ .../core/currency-minimum-amount.enum.ts | 18 +++++ src/discord/core/open-exchange-rates.ts | 7 ++ src/discord/discord.module.ts | 7 +- .../services/discord/discord.service.ts | 39 ++++++++-- src/discord/services/money/money.service.ts | 71 +++++++++++++++++++ src/shared/core/profile.ts | 19 ++--- .../services/profile/profile.service.ts | 38 +++++++++- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 16 files changed, 247 insertions(+), 35 deletions(-) create mode 100644 src/discord/core/balance.ts create mode 100644 src/discord/core/currency-decimal-places.enum.ts create mode 100644 src/discord/core/currency-minimum-amount.enum.ts create mode 100644 src/discord/core/open-exchange-rates.ts create mode 100644 src/discord/services/money/money.service.ts diff --git a/.gitignore b/.gitignore index 35f9727..0209f55 100644 --- a/.gitignore +++ b/.gitignore @@ -36,3 +36,6 @@ lerna-debug.log* # Config /config/* !/config/default.json + +# Docs +/documentation \ No newline at end of file diff --git a/config/default.json b/config/default.json index 858ddc3..9e24cef 100644 --- a/config/default.json +++ b/config/default.json @@ -11,9 +11,14 @@ "wallet": { "app": "", "endpoint": "", + "api": "", "client": 0, "secret": "", "redirect_uri": "", "scope": "" + }, + "fx": { + "url": "", + "client_id": "" } } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 36c8b49..0f8f71b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7948,6 +7948,11 @@ "minimist": "0.0.8" } }, + "money": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/money/-/money-0.2.0.tgz", + "integrity": "sha1-fq2i3xAJ35NfoY1PsdYg3azOAsI=" + }, "morgan": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.9.1.tgz", diff --git a/package.json b/package.json index 342c3fd..8c28ce3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "crypto-js": "^3.1.9-1", "decimal.js": "^10.2.0", "discord.js": "^11.5.1", + "money": "^0.2.0", "nestjs-redis": "^1.2.5", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.0", diff --git a/src/app.controller.ts b/src/app.controller.ts index dc29ced..f0afb71 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -5,7 +5,7 @@ import config from 'config'; import { ProfileService } from './shared/services/profile/profile.service'; import { Profile } from './shared/core/profile'; import { Observable, of } from 'rxjs'; -import { map, tap, catchError } from 'rxjs/operators' +import { map, tap, catchError, mergeMap } from 'rxjs/operators' @Controller() export class AppController { @@ -35,6 +35,9 @@ export class AppController { return of('Error Linking account, please try again') } + // Create profile + const user = new Profile({ id }) + // Exchange code for token return this.http.post(`${config.get('wallet.endpoint')}/oauth/token`, { grant_type: 'authorization_code', @@ -43,28 +46,30 @@ export class AppController { redirect_uri: config.get('wallet.redirect_uri'), code }).pipe( + // Pull out token and save map(d => d.data.access_token), tap(token => console.log('Token:', token)), - map(token => { - // TODO: Get data from server - - // Create profile and save - const user = new Profile({ - id, - token, - balance: { - available: 0, - pending: 0 - }, - currency: 'SGD' - }) + tap(token => Object.assign(user, { token })), + // Get data from server + mergeMap(token => this.http.get( + `${config.get('wallet.api')}/me`, { + headers: { + 'Authorization': `Bearer ${token}` + } + } + )), + map(a => a.data), + map(account => { + // Set user currency + user.currency = account.defaultCurrency; console.log('Account', user); console.log('JSON:', JSON.stringify(user)); this._profile.save(user); - // Return success page + // Return success return 'Account successfully linked!' }), + // Return error catchError(e => { console.warn(e); return 'Error occured'; diff --git a/src/discord/core/balance.ts b/src/discord/core/balance.ts new file mode 100644 index 0000000..7819dfa --- /dev/null +++ b/src/discord/core/balance.ts @@ -0,0 +1,12 @@ +export interface BalanceAmount { + amount: number; + currency: string; +} + +export interface Balance { + object: string; + available: BalanceAmount[]; + connect_reserved?: BalanceAmount[]; + livemode: boolean; + pending: BalanceAmount[]; +} \ No newline at end of file diff --git a/src/discord/core/currency-decimal-places.enum.ts b/src/discord/core/currency-decimal-places.enum.ts new file mode 100644 index 0000000..657e124 --- /dev/null +++ b/src/discord/core/currency-decimal-places.enum.ts @@ -0,0 +1,18 @@ +export enum CurrencyDecimalPlaces { + DKK = 2, + EUR = 2, + NOK = 2, + PLN = 2, + SEK = 2, + CHF = 2, + AUD = 2, + CAD = 2, + HKD = 2, + INR = 2, + MXN = 2, + NZD = 2, + SGD = 2, + GBP = 2, + USD = 2, + JPY = 0, +} diff --git a/src/discord/core/currency-minimum-amount.enum.ts b/src/discord/core/currency-minimum-amount.enum.ts new file mode 100644 index 0000000..bb0dc36 --- /dev/null +++ b/src/discord/core/currency-minimum-amount.enum.ts @@ -0,0 +1,18 @@ +export enum CurrencyMinimumAmount { + DKK = 2.50, + EUR = 1, + NOK = 3, + PLN = 1, + SEK = 3, + CHF = 1, + AUD = 1, + CAD = 1, + HKD = 4, + INR = 1, + MXN = 10, + NZD = 1, + SGD = 1, + GBP = 1, + USD = 1, + JPY = 100 +} diff --git a/src/discord/core/open-exchange-rates.ts b/src/discord/core/open-exchange-rates.ts new file mode 100644 index 0000000..e11aa71 --- /dev/null +++ b/src/discord/core/open-exchange-rates.ts @@ -0,0 +1,7 @@ +export interface OpenExchangeRates { + timestamp: number; + base: string; + rates: { + [key: string]: number + } +} diff --git a/src/discord/discord.module.ts b/src/discord/discord.module.ts index 4576919..2862d9e 100644 --- a/src/discord/discord.module.ts +++ b/src/discord/discord.module.ts @@ -1,11 +1,16 @@ -import { Module, Global } from '@nestjs/common'; +import { Module, Global, HttpModule } from '@nestjs/common'; import { DiscordService } from './services/discord/discord.service'; import { ProfileService } from '../shared/services/profile/profile.service'; import { TransactionService } from '../shared/services/transaction/transaction.service'; +import { MoneyService } from './services/money/money.service'; @Global() @Module({ + imports: [ + HttpModule + ], providers: [ + MoneyService, DiscordService, ProfileService, TransactionService diff --git a/src/discord/services/discord/discord.service.ts b/src/discord/services/discord/discord.service.ts index a8cda01..570586f 100644 --- a/src/discord/services/discord/discord.service.ts +++ b/src/discord/services/discord/discord.service.ts @@ -9,6 +9,8 @@ import { Transaction } from '../../../shared/core/transaction'; import { ProfileService } from '../../../shared/services/profile/profile.service'; import { TransactionService } from '../../../shared/services/transaction/transaction.service'; import { Args } from '../../core/args'; +import { BalanceAmount } from 'src/discord/core/balance'; +import { MoneyService } from '../money/money.service'; interface Keywords { currency: string[]; @@ -30,6 +32,7 @@ export class DiscordService { } constructor( + private _money: MoneyService, private _profile: ProfileService, private _transaction: TransactionService ) { @@ -52,6 +55,7 @@ export class DiscordService { } private async onMessage(message: Message): Promise { + let channel: DMChannel let user: Profile const args = new Args(message.content) console.log('Received', args) @@ -87,7 +91,7 @@ export class DiscordService { } // Create DM channel - const channel = await message.author.createDM() + channel = await message.author.createDM() // Send link to channel if (channel) { @@ -96,7 +100,11 @@ export class DiscordService { .setTitle('NR Wallet Login Link') .setDescription(`Hey ${message.author.toString()}, you can link your wallet here. You'll be asked to login and then you'll be directed back to our website once your account is linked! Just click the title right there.`) .setColor(DiscordService.colours.info) - .setURL(`${config.get('wallet.endpoint')}/oauth/authorize?client_id=${config.get('wallet.client')}&redirect_uri=${config.get('wallet.redirect_uri')}&response_type=code&scope=${config.get('wallet.scope')}&state=${message.author.id},${SHA256(message.author.id, config.get('wallet.secret')).toString(enc.Hex)}`) + .setURL( + encodeURI( + `${config.get('wallet.endpoint')}/oauth/authorize?client_id=${config.get('wallet.client')}&redirect_uri=${config.get('wallet.redirect_uri')}&response_type=code&scope=${config.get('wallet.scope')}&state=${message.author.id},${SHA256(message.author.id, config.get('wallet.secret')).toString(enc.Hex)}` + ) + ) .setThumbnail('https://glamsquad.sgp1.cdn.digitaloceanspaces.com/SocialHub/default/images/Logo_Transparent%20White.png') ) @@ -117,10 +125,11 @@ export class DiscordService { // Check if user is linked if (!this.linkCheck(user, message)) return - // TODO: Update user balance - - // Send users available and pending balance - message.reply(`Available balance: ${user.balance.available} ${user.currency}\nPending balance: ${user.balance.pending} ${user.currency}`) + // Get user balance + this._profile.balance(user).subscribe((b: BalanceAmount) => { + // Send users available and pending balance + message.reply(`Available balance: ${this._money.format(b.amount, b.currency)}`); + }); break case '$wallet': @@ -243,10 +252,26 @@ export class DiscordService { // Send warning if guild if (message.guild) { - message.reply(`Shhh... we shouldn't talk about that here`) + message.reply(`Shhh... we shouldn't talk about that here, I'll DM you`) } // TODO: Get sources and send to DM + + // Create DM channel + channel = await message.author.createDM() + + // Send link to channel + if (channel) { + this._profile.sources(user).subscribe(s => + channel.send(`Hey ${message.author.toString()}, your current sources:\n${ + s.reduce((str, source, i, arr) => str += `${source.card.brand[0].toUpperCase() + source.card.brand.slice(1)} ${source.type[0].toUpperCase() + source.type.slice(1)} - ${source.card.last4} (${source.card.exp_month}/${source.card.exp_year})${i !== arr.length-1 ? '\n':''}`, '') + }`) + ) + return + } + + // Send error if no channel + message.reply(`Hey uhm, I couldn't DM you, can you make sure I'm not blocked or anything?`) break } } diff --git a/src/discord/services/money/money.service.ts b/src/discord/services/money/money.service.ts new file mode 100644 index 0000000..12dcb3f --- /dev/null +++ b/src/discord/services/money/money.service.ts @@ -0,0 +1,71 @@ +import { Injectable, HttpService } from '@nestjs/common'; +import Decimal from 'decimal.js'; +import * as fx from 'money'; +import { Observable, timer } from 'rxjs'; +import { map, share, mergeMap } from 'rxjs/operators'; +import config from 'config'; +import { OpenExchangeRates } from 'src/discord/core/open-exchange-rates'; +import { CurrencyDecimalPlaces } from 'src/discord/core/currency-decimal-places.enum'; + +@Injectable() +export class MoneyService { + constructor(private http: HttpService) { + timer(0, 1000 * 60 * 60).pipe( + mergeMap(() => this.updateRates()), + share() + ); + } + + // Take base currency and convert to a displayable decimal + format(amount: number, currency: string): string { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency + }).format(this.compress(amount, currency)); + } + + // Take the number input and convert to base currency + unformat(amount: number, currency: string): number { + return this.uncompress(amount, currency); + } + + // Compress format to decimal amount + compress(amount: number, currency: string): number { + return new Decimal(amount).div(new Decimal(10).pow(CurrencyDecimalPlaces[currency.toUpperCase()])).toNumber(); + } + + // Uncompress format to base denomination + uncompress(amount: number, currency: string): number { + return new Decimal(amount).times(new Decimal(10).pow(CurrencyDecimalPlaces[currency.toUpperCase()])).toNumber(); + } + + // Take a base currency and convert to a secondary currency + convert(amount: number, from: string, to: string): Observable { + return this.updateRates().pipe( + // Compress in base currency, convert to fx and uncompress using fx + map(() => Math.round( + this.uncompress( + fx(this.compress(amount, from)).convert({ from: from.toUpperCase(), to: to.toUpperCase() }), + from + ) + )) + ) + } + + // API: Update exchange rates + updateRates(): Observable { + return this.http.get( + `${config.get('fx.url')}`, { + params: { app_id: config.get('fx.client_id') } + } + ).pipe( + map(d => d.data), + map((data: OpenExchangeRates) => { + console.log('Open exchange rates:', data) + fx.base = data.base; + fx.rates = data.rates; + return data + }) + ) + } +} diff --git a/src/shared/core/profile.ts b/src/shared/core/profile.ts index b5fb884..2beb9bd 100644 --- a/src/shared/core/profile.ts +++ b/src/shared/core/profile.ts @@ -1,16 +1,19 @@ +import { HttpService } from "@nestjs/common"; + export class Profile { public token: string; - public balance: { - available: number; - pending: number; - } public currency: string; - public sources: any; public id: string; - public linked = false; - constructor (data?: object) { + constructor (data: {id?: string; token?: string; currency?: string}, private readonly http?: HttpService) { Object.assign(this, data) - this.linked = !!this.token + } + + get linked(): boolean { + return !!this.token; + } + + set linked(linked: boolean) { + return } } diff --git a/src/shared/services/profile/profile.service.ts b/src/shared/services/profile/profile.service.ts index c281cc6..e726c84 100644 --- a/src/shared/services/profile/profile.service.ts +++ b/src/shared/services/profile/profile.service.ts @@ -1,13 +1,20 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, HttpService } from '@nestjs/common'; import { RedisService } from 'nestjs-redis'; import { Profile } from '../../core/profile'; import { Redis } from 'ioredis'; +import config from 'config'; +import { Balance, BalanceAmount } from 'src/discord/core/balance'; +import { Observable } from 'rxjs'; +import { map, tap } from 'rxjs/operators'; @Injectable() export class ProfileService { private client: Redis; - constructor(private _redis: RedisService) { + constructor( + private _redis: RedisService, + private http: HttpService + ) { this.client = _redis.getClient(); } @@ -24,4 +31,31 @@ export class ProfileService { ) ) } + + public sources(user: Profile): Observable { + return this.http.get( + `${config.get('wallet.api')}/me/payment/source`, { + headers: { + 'Authorization': `Bearer ${user.token}` + } + } + ).pipe( + map(d => d.data), + tap(s => console.log('Retrieved sources:', s)) + ); + } + + public balance(user: Profile): Observable { + return this.http.get( + `${config.get('wallet.api')}/me/balance`, { + headers: { + 'Authorization': `Bearer ${user.token}` + } + } + ).pipe( + map(d => d.data), + map((balances: Balance) => balances.available.find(b => b.currency == user.currency)), + tap(b => console.log('Retrieved balance:', b)) + ); + } } diff --git a/tsconfig.build.json b/tsconfig.build.json index 64f86c6..cb8044d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] + "exclude": ["node_modules", "test", "dist", "documentation", "**/*spec.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 3b4bb2c..3e9e665 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,5 +12,5 @@ "baseUrl": "./", "incremental": true }, - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "documentation"] }