initial commit

This commit is contained in:
Patrick Nagurny
2018-10-19 11:28:08 -04:00
commit 5ff09d328d
139 changed files with 23448 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
import { AccountService } from './account.service';
import { ApiService } from './api.service';
import { WebSocketService } from './websocket.service';
import { TransactionService } from './transaction.service';
import { PriceService } from './price.service';
import { SessionService } from './session.service';
import { Observable } from 'rxjs/Observable';
import { EmptyObservable } from 'rxjs/observable/EmptyObservable';
import { Logger } from '../core/logger';
import { AccountApi } from '../shared/account';
import { Transaction } from '../shared/transaction';
import { Price } from '../shared/price';
import { Org } from '../shared/org';
var rawAccounts = [
new AccountApi({
id: '1',
orgId: '1',
name: 'Root',
currency: 'USD',
precision: 2,
debitBalance: true
}),
new AccountApi({
id: '2',
orgId: '1',
name: 'Assets',
currency: 'USD',
precision: 2,
debitBalance: true,
parent: '1'
}),
new AccountApi({
id: '3',
orgId: '1',
name: 'Liabilities',
currency: 'USD',
precision: 2,
debitBalance: false,
parent: '1'
}),
new AccountApi({
id: '4',
orgId: '1',
name: 'Equity',
currency: 'USD',
precision: 2,
debitBalance: false,
parent: '1'
}),
new AccountApi({
id: '5',
orgId: '1',
name: 'Bitcoin',
currency: 'BTC',
precision: 8,
debitBalance: true,
parent: '2',
balance: 1000000,
nativeBalance: 7000
}),
new AccountApi({
id: '6',
orgId: '1',
name: 'Current Assets',
currency: 'USD',
precision: 2,
debitBalance: true,
parent: '2'
}),
new AccountApi({
id: '7',
orgId: '1',
name: 'Checking',
currency: 'USD',
precision: 2,
debitBalance: true,
parent: '6',
balance: 1000,
nativeBalance: 1000
}),
new AccountApi({
id: '8',
orgId: '1',
name: 'Savings',
currency: 'USD',
precision: 2,
debitBalance: true,
parent: '6',
balance: 2000,
nativeBalance: 2000
})
];
class Mock {
}
class ApiMock {
getAccounts() {
return Observable.of(rawAccounts);
}
}
class SessionMock {
getSessions() {
return new EmptyObservable();
}
}
class TransactionMock {
getNewTransactions() {
return new EmptyObservable();
}
getDeletedTransactions() {
return new EmptyObservable();
}
getRecentTransactions() {
let txs = [
new Transaction({
id: '1',
date: new Date('2018-09-24'),
splits: [
{
accountId: '7',
amount: -1000,
nativeAmount: -1000
},
{
accountId: '4',
amount: 1000,
nativeAmount: 1000
}
]
})
];
return Observable.of(txs);
}
}
class PriceMock {
getPricesNearestInTime() {
let prices = [
new Price({
id: '1',
currency: 'BTC',
date: new Date('2018-09-24'),
price: 10000
})
];
return Observable.of(prices);
}
}
describe('AccountService', () => {
describe('#getAccountTree', () => {
it('should correctly create an AccountTree', (done) => {
let as = new AccountService(
new Logger,
new ApiMock() as ApiService,
new Mock() as WebSocketService,
new TransactionMock() as any,
new PriceMock() as any,
new SessionMock() as any
);
as['accountWs$'] = Observable.empty();
as['org'] = new Org({
id: '1',
currency: 'USD',
precision: 2
});
as.getAccountTree().subscribe(tree => {
console.log(tree);
expect(tree.rootAccount.name).toEqual('Root');
expect(tree.rootAccount.depth).toEqual(0);
expect(tree.rootAccount.totalBalance).toEqual(3000);
expect(tree.rootAccount.totalNativeBalanceCost).toEqual(10000);
expect(tree.rootAccount.totalNativeBalancePrice).toEqual(13000);
expect(tree.rootAccount.children.length).toEqual(3);
expect(tree.rootAccount.children[0].name).toEqual('Assets');
expect(tree.rootAccount.children[0].fullName).toEqual('Assets');
expect(tree.rootAccount.children[0].depth).toEqual(1);
expect(tree.rootAccount.children[0].totalBalance).toEqual(3000);
expect(tree.rootAccount.children[0].totalNativeBalanceCost).toEqual(10000);
expect(tree.rootAccount.children[0].totalNativeBalancePrice).toEqual(13000);
expect(tree.rootAccount.children[1].name).toEqual('Equity');
expect(tree.rootAccount.children[1].fullName).toEqual('Equity');
expect(tree.rootAccount.children[1].depth).toEqual(1);
expect(tree.rootAccount.children[1].totalBalance).toEqual(0);
expect(tree.rootAccount.children[2].name).toEqual('Liabilities');
expect(tree.rootAccount.children[2].fullName).toEqual('Liabilities');
expect(tree.rootAccount.children[2].depth).toEqual(1);
expect(tree.rootAccount.children[2].totalBalance).toEqual(0);
let assets = tree.rootAccount.children[0];
expect(assets.children.length).toEqual(2);
expect(assets.children[0].name).toEqual('Bitcoin');
expect(assets.children[0].fullName).toEqual('Assets:Bitcoin');
expect(assets.children[0].depth).toEqual(2);
expect(assets.children[0].totalBalance).toEqual(1000000);
expect(assets.children[0].totalNativeBalanceCost).toEqual(7000);
expect(assets.children[0].totalNativeBalancePrice).toEqual(10000);
expect(assets.children[1].name).toEqual('Current Assets');
expect(assets.children[1].fullName).toEqual('Assets:Current Assets');
expect(assets.children[1].depth).toEqual(2);
expect(assets.children[1].totalBalance).toEqual(3000);
let currentAssets = assets.children[1];
expect(currentAssets.children.length).toEqual(2);
expect(currentAssets.children[0].name).toEqual('Checking');
expect(currentAssets.children[0].fullName).toEqual('Assets:Current Assets:Checking');
expect(currentAssets.children[0].depth).toEqual(3);
expect(currentAssets.children[0].totalBalance).toEqual(1000);
expect(currentAssets.children[1].name).toEqual('Savings');
expect(currentAssets.children[1].fullName).toEqual('Assets:Current Assets:Savings');
expect(currentAssets.children[1].depth).toEqual(3);
expect(currentAssets.children[1].totalBalance).toEqual(2000);
done();
});
});
});
describe('#getRawAccountMap', () => {
it('should correctly create a raw account map', (done) => {
let as = new AccountService(
new Logger,
new ApiMock() as ApiService,
new Mock() as WebSocketService,
new TransactionMock() as any,
new PriceMock() as any,
new SessionMock() as any
);
as['accountWs$'] = Observable.empty();
as.getRawAccountMap().subscribe(accountMap => {
expect(Object.keys(accountMap).length).toEqual(rawAccounts.length);
expect(accountMap['5'].price).toEqual(10000);
expect(accountMap['7'].recentTxCount).toEqual(1);
expect(accountMap['8'].recentTxCount).toEqual(0);
done();
}, (err) => {
throw err;
});
});
});
});

View File

@@ -0,0 +1,649 @@
import { Injectable } from '@angular/core';
import { Logger } from './logger';
import { ApiService } from './api.service';
import { WebSocketService } from './websocket.service';
import { TransactionService } from './transaction.service';
import { SessionService } from './session.service';
import { PriceService } from './price.service';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { concat } from 'rxjs/observable/concat';
import { merge } from 'rxjs/observable/merge';
import { Account, AccountApi, AccountTree } from '../shared/account';
import { Transaction } from '../shared/transaction';
import { Org } from '../shared/org';
import { Price } from '../shared/price';
import { Message } from '../shared/message';
import 'rxjs/add/observable/combineLatest';
import 'rxjs/add/operator/concat';
import 'rxjs/add/operator/shareReplay';
import 'rxjs/add/observable/empty';
import 'rxjs/add/operator/startWith';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/debounceTime';
import 'rxjs/add/operator/take';
import { Util } from '../shared/util';
import { personalAccounts } from '../fixtures/personalAccounts';
import { ErrorObservable } from 'rxjs/observable/ErrorObservable';
@Injectable()
export class AccountService {
private rawAccountMap$: Observable<{[accountId: string]: AccountApi}>;
private rawAccountMaps: any = {};
private accountWs$: Observable<Message>;
private accountSubscription: Subscription;
private org: Org;
constructor(
private log: Logger,
private apiService: ApiService,
private wsService: WebSocketService,
private txService: TransactionService,
private priceService: PriceService,
private sessionService: SessionService) {
this.sessionService.getSessions().subscribe(([user, org, options]) => {
this.log.debug('accountService new session');
// cleanup after old session
this.rawAccountMap$ = null;
this.rawAccountMaps = {};
if(this.accountWs$ && this.org) {
this.wsService.unsubscribe('account', this.org.id);
this.accountWs$ = null;
}
this.org = org;
if(org) {
// subscribe to web socket
this.accountWs$ = this.wsService.subscribe('account', org.id);
if(options.createDefaultAccounts) {
this.getAccountTree().take(1).switchMap(tree => {
return this.createDefaultAccounts(tree);
}).subscribe(accounts => {
log.debug('Created default accounts');
log.debug(accounts);
}, err => {
log.error('Error creating default accounts');
log.error(err);
})
}
}
});
}
getRawSocketAccounts(): Observable<AccountApi> {
return this.accountWs$.filter(message => {
return message.action === 'create' || message.action === 'update';
}).map(message => {
return new AccountApi(message.data);
});
}
getRawAccountMap(): Observable<{[accountId: string]: AccountApi}> {
this.log.debug('getRawAccountMap()');
if(!this.rawAccountMap$) {
let emptyTx$ = Observable.of(new Transaction({splits: []}));
let newTxs$ = concat(emptyTx$, this.txService.getNewTransactions());
let deletedTxs$ = concat(emptyTx$, this.txService.getDeletedTransactions());
this.rawAccountMap$ = this.txService.getRecentTransactions().map(recentTxs => {
this.log.debug('recentTxs');
return recentTxs.reduce((acc, tx) => {
tx.splits.forEach(split => {
acc[split.accountId] = (acc[split.accountId] || 0) + 1;
});
return acc;
}, {});
})
.switchMap(txCounts => {
this.log.debug('txCounts');
this.log.debug(txCounts);
return this.apiService.getAccounts().map(rawAccounts => {
let rawAccountMap = {};
rawAccounts.forEach(rawAccount => {
rawAccountMap[rawAccount.id] = rawAccount;
rawAccount.recentTxCount = txCounts[rawAccount.id] || 0;
})
return rawAccountMap;
})
})
.switchMap(rawAccountMap => {
this.log.debug('rawAccountMap');
this.log.debug(rawAccountMap);
return concat(Observable.of(null), this.accountWs$).map(message => {
if(message && message.data) {
let rawAccount = new AccountApi(message.data);
switch(message.action) {
case 'create':
case 'update':
rawAccountMap[rawAccount.id] = rawAccount;
break;
case 'delete':
delete rawAccountMap[rawAccount.id];
}
}
return rawAccountMap;
})
})
.switchMap(rawAccountMap => {
return this.priceService.getPricesNearestInTime(new Date()).map(prices => {
this.log.debug(prices);
prices.forEach(price => {
for(let id in rawAccountMap) {
let rawAccount = rawAccountMap[id];
if(rawAccount.currency === price.currency) {
rawAccount.price = price.price;
}
}
});
return rawAccountMap;
});
})
.switchMap(rawAccountMap => {
this.log.debug('newtxs');
return newTxs$.map(tx => {
for(let split of tx.splits) {
let rawAccount = rawAccountMap[split.accountId];
if(rawAccount) {
rawAccount.balance += split.amount;
rawAccount.nativeBalance += split.nativeAmount;
rawAccount.recentTxCount++;
}
}
return rawAccountMap;
})
})
.switchMap(rawAccountMap => {
this.log.debug('deletedtxs');
return deletedTxs$.map(tx => {
for(let split of tx.splits) {
let rawAccount = rawAccountMap[split.accountId];
if(rawAccount) {
rawAccount.balance -= split.amount;
rawAccount.nativeBalance -= split.nativeAmount;
rawAccount.recentTxCount--;
}
}
return rawAccountMap;
})
})
.debounceTime(500)
.shareReplay(1);
}
return this.rawAccountMap$;
}
getRawAccountMapAtDate(date: Date): Observable<{[accountId: string]: AccountApi}> {
if(!this.rawAccountMaps[date.getTime()]) {
let emptyTx$ = Observable.of(new Transaction({splits: []}));
let newTxs$ = concat(emptyTx$, this.txService.getNewTransactions());
let deletedTxs$ = concat(emptyTx$, this.txService.getDeletedTransactions());
this.rawAccountMaps[date.getTime()] = this.apiService.getAccounts(date).map(rawAccounts => {
let rawAccountMap = {};
rawAccounts.forEach(rawAccount => {
rawAccountMap[rawAccount.id] = rawAccount;
})
return rawAccountMap;
})
.switchMap(rawAccountMap => {
return this.priceService.getPricesNearestInTime(date).map(prices => {
this.log.debug(prices);
prices.forEach(price => {
for(let id in rawAccountMap) {
let rawAccount = rawAccountMap[id];
if(rawAccount.currency === price.currency) {
rawAccount.price = price.price;
}
}
});
return rawAccountMap;
});
})
.switchMap(rawAccountMap => {
this.log.debug('newtxs');
return newTxs$.filter(tx => {
return tx.date < date;
}).map(tx => {
for(let split of tx.splits) {
let rawAccount = rawAccountMap[split.accountId];
if(rawAccount) {
rawAccount.balance += split.amount;
rawAccount.nativeBalance += split.nativeAmount;
}
}
return rawAccountMap;
})
})
.switchMap(rawAccountMap => {
this.log.debug('deletedtxs');
return deletedTxs$.filter(tx => {
return tx.date < date;
}).map(tx => {
for(let split of tx.splits) {
let rawAccount = rawAccountMap[split.accountId];
if(rawAccount) {
rawAccount.balance -= split.amount;
rawAccount.nativeBalance -= split.nativeAmount;
}
}
return rawAccountMap;
})
})
.debounceTime(500)
.shareReplay(1);
}
return this.rawAccountMaps[date.getTime()];
}
getAccountTree(): Observable<AccountTree> {
return this.getRawAccountMap()
.map(rawAccountMap => {
this.log.debug('accountTree: rawAccountMap');
this.log.debug(rawAccountMap);
let accountMap = {};
let rootAccount = null;
for(let id in rawAccountMap) {
let rawAccount = rawAccountMap[id];
let account = new Account(rawAccount);
account.parent = null;
account.orgCurrency = this.org.currency;
account.orgPrecision = this.org.precision;
accountMap[account.id] = account;
}
for(let id in rawAccountMap) {
let rawAccount = rawAccountMap[id];
let account = accountMap[id];
if(accountMap[rawAccount.parent]) {
account.parent = accountMap[rawAccount.parent];
account.parent.children.push(account);
// sort children alphabetically
account.parent.children.sort((a, b) => {
return a.name.localeCompare(b.name);
});
} else {
rootAccount = account;
}
}
this.log.debug('rootAccount', rootAccount);
// cache account (for transaction consumers)
return new AccountTree({
rootAccount: rootAccount,
accountMap: accountMap
});
})
.map(tree => this._addDepths(tree))
.map(tree => this._addFullNames(tree))
.map(tree => this._updateBalances(tree));
}
getAccountTreeAtDate(date: Date): Observable<AccountTree> {
return this.getRawAccountMapAtDate(date).map(rawAccountMap => {
this.log.debug('rawAccounts');
this.log.debug(rawAccountMap);
let accountMap = {};
let rootAccount = null;
for(let id in rawAccountMap) {
let rawAccount = rawAccountMap[id];
let account = new Account(rawAccount);
account.orgCurrency = this.org.currency;
account.orgPrecision = this.org.precision;
accountMap[account.id] = account;
}
for(let id in rawAccountMap) {
let rawAccount = rawAccountMap[id];
let account = accountMap[id];
if(accountMap[rawAccount.parent]) {
account.parent = accountMap[rawAccount.parent];
account.parent.children.push(account);
// sort children alphabetically
account.parent.children.sort((a, b) => {
return a.name.localeCompare(b.name);
});
} else {
rootAccount = account;
}
}
return new AccountTree({
rootAccount: rootAccount,
accountMap: accountMap
});
})
.map(tree => this._addDepths(tree))
.map(tree => this._addFullNames(tree))
.map(tree => this._updateBalances(tree));
}
getAccountTreeWithPeriodBalance(startDate: Date, endDate?: Date): Observable<AccountTree> {
let startTree$ = this.getAccountTreeAtDate(startDate);
let endTree$ = endDate ? this.getAccountTreeAtDate(endDate) : this.getAccountTree();
return Observable.combineLatest(startTree$, endTree$)
.map(([start, end]) => {
// function is impure... but convenient
// consider making it pure
for(let accountId in end.accountMap) {
let account = end.accountMap[accountId];
let startAccount = start.accountMap[accountId];
this.log.debug(account.name, startAccount ? startAccount.balance : 0, account.balance);
// TODO maybe there is a better way of dealing with price / balance for non-native currencies
let balancePriceDelta = account.balance * account.price - (startAccount ? startAccount.balance * startAccount.price : 0);
let balanceDelta = account.balance - (startAccount ? startAccount.balance : 0);
let weightedPrice = 0;
if(balanceDelta) {
weightedPrice = balancePriceDelta / balanceDelta;
}
account.balance -= startAccount ? startAccount.balance : 0;
account.nativeBalanceCost -= startAccount ? startAccount.nativeBalanceCost : 0;
account.nativeBalancePrice -= startAccount ? startAccount.nativeBalancePrice : 0;
account.totalBalance -= startAccount ? startAccount.totalBalance : 0;
account.totalNativeBalanceCost -= startAccount ? startAccount.totalNativeBalanceCost : 0;
account.totalNativeBalancePrice -= startAccount ? startAccount.totalNativeBalancePrice : 0;
account.price = weightedPrice;
}
this.log.debug('accountTreeWithPeriodBalance');
this.log.debug(end);
return end;
});
}
getFlattenedAccounts(): Observable<any> {
return this.getAccountTree().map(tree => {
return this._getFlattenedAccounts(tree.rootAccount);
});
}
getFlattenedAccountsWithPeriodBalance(startDate: Date, endDate?: Date): Observable<Account[]> {
return this.getAccountTreeWithPeriodBalance(startDate, endDate).map(tree => {
return this._getFlattenedAccounts(tree.rootAccount);
});
}
_getFlattenedAccounts(node: Account): Account[] {
let flattened = [];
for(let account of node.children) {
flattened.push(account);
flattened = flattened.concat(this._getFlattenedAccounts(account));
}
return flattened;
}
getAccountByName (accounts: Account[], name: string): Account {
for(let account of accounts) {
// TODO pass in depth
if(account.name === name && account.depth === 1) {
return account;
}
}
return null;
}
sortAccountsAlphabetically(accounts) {
accounts.sort((a, b) => {
let nameA = a.name.toLowerCase();
let nameB = b.name.toLowerCase();
if (nameA < nameB)
return -1;
if (nameA > nameB)
return 1;
return 0;
});
}
_addDepths(tree: AccountTree): AccountTree {
for(let id in tree.accountMap) {
let account = tree.accountMap[id];
let node = account;
let depth = 0;
while(node.parent) {
depth++;
node = node.parent;
}
account.depth = depth;
}
return tree;
}
_addFullNames(tree: AccountTree): AccountTree {
for(let id in tree.accountMap) {
let account = tree.accountMap[id];
let node = account;
let accountArray = [account.name];
while(node.parent && node.parent.depth > 0) {
node = node.parent;
accountArray.unshift(node.name);
}
account.fullName = accountArray.join(':');
}
return tree;
}
_updateBalances(tree: AccountTree): AccountTree {
// TODO impure function
// first zero out balances. not necessary if all functions are pure
for(let accountId in tree.accountMap) {
let account = tree.accountMap[accountId];
account.totalBalance = account.balance;
account.totalNativeBalanceCost = account.nativeBalanceCost;
if(account.currency === this.org.currency) {
account.nativeBalancePrice = account.balance;
} else {
account.nativeBalancePrice = account.balance * account.price / Math.pow(10, account.precision - this.org.precision);
}
account.totalNativeBalancePrice = account.nativeBalancePrice;
}
// update balances
for(let accountId in tree.accountMap) {
let account = tree.accountMap[accountId];
if(!account.children.length) {
let parent = account.parent;
while(parent) {
parent.totalNativeBalanceCost += account.totalNativeBalanceCost;
parent.totalNativeBalancePrice += account.totalNativeBalancePrice;
if(parent.currency === account.currency) {
parent.totalBalance += account.totalBalance;
}
parent = parent.parent;
}
}
}
return tree;
}
getAccountTreeFromName(name: string, rootNode: Account) {
for(var i = 0; i < rootNode.children.length; i++) {
let child = rootNode.children[i];
if(child.name === name) {
return child;
}
try {
let account = this.getAccountTreeFromName(name, child);
return account;
} catch(e) {
// ignore
}
}
throw new Error('Account not found ' + name);
}
getAccountAtoms(rootNode: Account): Account[] {
let accounts = [];
for(let i = 0; i < rootNode.children.length; i++) {
let child = rootNode.children[i];
if(!child.children.length) {
accounts.push(child);
} else {
accounts = accounts.concat(this.getAccountAtoms(child));
}
}
return accounts;
}
// getSelectBoxAccountAtoms(rootNode: Account): any[] {
// var data = [];
// for(let account of rootNode.children) {
// if(!account.children.length) {
// data.push({
// id: account.id,
// name: this.getAccountHierarchyString(account),
// debitBalance: account.debitBalance
// });
// }
// let childData = this.getSelectBoxAccountAtoms(account);
// data = data.concat(childData);
// }
// return data;
// }
accountIsChildOf(account: Account, parent: Account) {
for(let child of parent.children) {
if(child.id === account.id) {
return true;
}
if(this.accountIsChildOf(account, child)) {
return true;
}
}
return false;
}
newAccount(account: AccountApi): Observable<Account> {
return this.apiService.postAccount(account)
.map(rawAccount => {
let account = new Account(rawAccount);
account.orgCurrency = this.org.currency;
account.orgPrecision = this.org.precision;
return account;
});
}
putAccount(account: AccountApi): Observable<Account> {
return this.apiService.putAccount(account)
.map(rawAccount => {
let account = new Account(rawAccount);
account.orgCurrency = this.org.currency;
account.orgPrecision = this.org.precision;
return account;
})
}
deleteAccount(id: string): Observable<any> {
return this.apiService.deleteAccount(id);
}
getPeriodStart(): Date {
let date = new Date();
date.setDate(1);
date.setHours(0, 0, 0, 0);
return date;
}
createDefaultAccounts(tree: AccountTree): Observable<any> {
let assetAccount = tree.getAccountByName('Assets', 1);
let equityAccount = tree.getAccountByName('Equity', 1);
let liabilityAccount = tree.getAccountByName('Liabilities', 1);
let incomeAccount = tree.getAccountByName('Income', 1);
let expenseAccount = tree.getAccountByName('Expenses', 1);
let currency = assetAccount.currency;
let precision = assetAccount.precision;
let accountNameMap = {
'Assets': [assetAccount.id, true],
'Equity': [equityAccount.id, false],
'Liabilities': [liabilityAccount.id, false],
'Income': [incomeAccount.id, false],
'Expenses': [expenseAccount.id, true]
};
let newAccounts;
try {
newAccounts = personalAccounts.map(data => {
let id = Util.newGuid();
let [parentId, debitBalance] = accountNameMap[data.parent];
if(!parentId) {
throw new Error('Parent does not exist ' + data.parent);
}
// TODO find a cleaner way of doing this without making assumptions
if(['Assets', 'Equity', 'Liabilities', 'Income', 'Expenses'].indexOf(data.parent) > -1) {
accountNameMap[data.name] = [id, debitBalance];
}
return new AccountApi({
id: id,
name: data.name,
currency: currency,
precision: precision,
debitBalance: debitBalance,
parent: parentId
})
});
} catch(e) {
return new ErrorObservable(e);
}
return this.apiService.postAccounts(newAccounts);
}
}

340
src/app/core/api.service.ts Normal file
View File

@@ -0,0 +1,340 @@
import { Injectable } from '@angular/core';
import { Logger } from './logger';
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { AccountApi } from '../shared/account';
import { Transaction } from '../shared/transaction';
import { Org } from '../shared/org';
import { User } from '../shared/user';
import { Price } from '../shared/price';
import { ApiKey } from '../shared/apikey';
import { Invite } from '../shared/invite';
import { Observable } from 'rxjs/Observable';
import { ErrorObservable } from 'rxjs/observable/ErrorObservable';
import { catchError, retry } from 'rxjs/operators';
import { AppError } from '../shared/error';
let logger;
@Injectable()
export class ApiService {
private url: string; // URL to web api
private httpOptions = {
headers: new HttpHeaders({
'content-type': 'application/json',
'accept-version': '^0.1.8'
})
};
private orgId: string;
private sessionId: string;
constructor(private log: Logger, private http: HttpClient) {
logger = log;
}
setUrl(url: string) {
this.log.debug('set url', url);
this.url = url;
}
verifyUser(code: string): Observable<any> {
return this.http.post<any>(this.url + '/user/verify', {code: code}, this.httpOptions)
.pipe(catchError(this.handleError));
}
resetPassword(email: string): Observable<any> {
return this.http.post<any>(this.url + '/user/reset-password', {email: email}, this.httpOptions)
.pipe(catchError(this.handleError));
}
confirmResetPassword(password: string, code: string): Observable<User> {
return this.http.put<User>(this.url + '/user', {password: password, code: code}, this.httpOptions)
.pipe(catchError(this.handleError));
}
newSession(email: string, password: string, sessionId: string): Observable<any> {
let url = this.url + '/sessions';
let tempHeaders = new HttpHeaders(this.httpOptions.headers.keys().reduce((acc, current) => {
acc[current] = this.httpOptions.headers.get(current);
return acc;
}, {}));
tempHeaders = tempHeaders.set('Authorization', 'Basic ' + window.btoa(email + ':' + password));
return this.http.post<any>(url, {id: sessionId}, {headers: tempHeaders})
.pipe(catchError(this.handleError));
}
logout() {
let url = this.url + '/sessions/' + this.sessionId;
this.http.delete<any>(url, this.httpOptions).subscribe(() => {
this.removeSessionInfo();
});
}
setSession(id: string) {
this.sessionId = id;
this.httpOptions.headers = this.httpOptions.headers.set('Authorization', 'Basic ' + window.btoa(id + ':'));
}
removeSessionInfo() {
this.httpOptions.headers.delete('Authorization');
this.sessionId = null;
}
setOrgId(orgId: string) {
this.orgId = orgId;
}
getAccounts (date?: Date): Observable<AccountApi[]> {
this.log.debug('API getAccounts()');
let url = this.url + '/orgs/' + this.orgId + '/accounts';
if(date) {
url += '?date=' + date.getTime();
}
return this.http.get<AccountApi[]>(url, this.httpOptions)
.pipe(catchError(this.handleError));
}
getTransactionsByAccount (accountId, options: any = {}): Observable<Transaction[]> {
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + accountId + '/transactions';
if(Object.keys(options).length) {
let optionsArray: string [] = [];
for(let option in options) {
optionsArray.push(option + '=' + options[option]);
}
url += '?' + optionsArray.join('&');
}
return this.http.get<Transaction[]>(url, this.httpOptions)
.map(transactions => {
return transactions.map(transaction => {
// TODO do this on all transactions
transaction = new Transaction(transaction);
transaction.date = new Date(transaction.date);
transaction.inserted = new Date(transaction.inserted);
transaction.updated = new Date(transaction.updated);
return transaction;
});
})
.pipe(catchError(this.handleError));
}
getTransactions(options: any = {}): Observable<Transaction[]> {
this.log.debug('API getTransactions()');
let url = this.url + '/orgs/' + this.orgId + '/transactions';
if(Object.keys(options).length) {
let optionsArray: string [] = [];
for(let option in options) {
optionsArray.push(option + '=' + options[option]);
}
url += '?' + optionsArray.join('&');
}
return this.http.get<Transaction[]>(url, this.httpOptions)
.map(transactions => {
return transactions.map(transaction => {
transaction.date = new Date(transaction.date);
transaction.inserted = new Date(transaction.inserted);
transaction.updated = new Date(transaction.updated);
return transaction;
});
})
.pipe(catchError(this.handleError));
}
postTransaction(transaction: Transaction): Observable<Transaction> {
return this.http.post<Transaction>(this.url + '/orgs/' + this.orgId + '/transactions', transaction, this.httpOptions)
.map(transaction => {
transaction.date = new Date(transaction.date);
transaction.inserted = new Date(transaction.inserted);
transaction.updated = new Date(transaction.updated);
return transaction;
})
.pipe(catchError(this.handleError));
}
putTransaction(oldId: string, transaction: Transaction): Observable<Transaction> {
let url = this.url + '/orgs/' + this.orgId + '/transactions/' + oldId;
return this.http.put<Transaction>(url, transaction, this.httpOptions)
.map(transaction => {
transaction.date = new Date(transaction.date);
transaction.inserted = new Date(transaction.inserted);
transaction.updated = new Date(transaction.updated);
return transaction;
})
.pipe(catchError(this.handleError));
}
deleteTransaction(id: string): Observable<any> {
let url = this.url + '/orgs/' + this.orgId + '/transactions/' + id;
return this.http.delete<any>(url, this.httpOptions)
.pipe(catchError(this.handleError));
}
postAccount(account: AccountApi): Observable<AccountApi> {
return this.http.post<AccountApi>(this.url + '/orgs/' + this.orgId + '/accounts', account, this.httpOptions)
.pipe(catchError(this.handleError));
}
postAccounts(accounts: AccountApi[]): Observable<AccountApi> {
return this.http.post<AccountApi[]>(this.url + '/orgs/' + this.orgId + '/accounts', accounts, this.httpOptions)
.pipe(catchError(this.handleError));
}
putAccount(account: AccountApi): Observable<AccountApi> {
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + account.id;
return this.http.put<AccountApi>(url, account, this.httpOptions)
.pipe(catchError(this.handleError));
}
deleteAccount(id: string): Observable<any> {
let url = this.url + '/orgs/' + this.orgId + '/accounts/' + id;
return this.http.delete<any>(url, this.httpOptions)
.pipe(catchError(this.handleError));
}
getOrg (orgId): Observable<Org> {
return this.http.get<Org>(this.url + '/orgs/' + orgId, this.httpOptions)
.pipe(catchError(this.handleError));
}
getOrgs (): Observable<Org[]> {
return this.http.get<Org[]>(this.url + '/orgs', this.httpOptions)
.pipe(catchError(this.handleError));
}
getUser (): Observable<User> {
return this.http.get<User>(this.url + '/user', this.httpOptions)
.pipe(catchError(this.handleError));
}
postUser(user: User): Observable<User> {
return this.http.post<User>(this.url + '/users', user, this.httpOptions)
.pipe(catchError(this.handleError));
}
putUser(user: User): Observable<User> {
return this.http.put<User>(this.url + '/user', user, this.httpOptions)
.pipe(catchError(this.handleError))
}
postOrg(org: Org): Observable<Org> {
return this.http.post<Org>(this.url + '/orgs', org, this.httpOptions)
.pipe(catchError(this.handleError));
}
getPricesNearestInTime(date: Date): Observable<Price[]> {
let query = '/orgs/' + this.orgId + '/prices?nearestDate=' + date.getTime();
return this.http.get<Price[]>(this.url + query, this.httpOptions)
.map(prices => {
return prices.map(price => {
price.date = new Date(price.date);
price.inserted = new Date(price.inserted);
price.updated = new Date(price.updated);
return price;
});
})
.pipe(catchError(this.handleError));
}
getPricesByCurrency(currency: string): Observable<Price[]> {
let query = '/orgs/' + this.orgId + '/prices?currency=' + currency;
return this.http.get<Price[]>(this.url + query, this.httpOptions)
.map(prices => {
return prices.map(price => {
price.date = new Date(price.date);
price.inserted = new Date(price.inserted);
price.updated = new Date(price.updated);
return price;
});
})
.pipe(catchError(this.handleError));
}
postPrice(price: Price): Observable<Price> {
return this.http.post<Price>(this.url + '/orgs/' + this.orgId + '/prices', price, this.httpOptions)
.pipe(catchError(this.handleError));
}
deletePrice(id: string): Observable<any> {
let url = this.url + '/orgs/' + this.orgId + '/prices/' + id;
return this.http.delete<any>(url, this.httpOptions)
.pipe(catchError(this.handleError));
}
getApiKeys(): Observable<ApiKey[]> {
return this.http.get<ApiKey[]>(this.url + '/apikeys', this.httpOptions)
.pipe(catchError(this.handleError));
}
postApiKey(key: ApiKey): Observable<ApiKey> {
return this.http.post<ApiKey>(this.url + '/apikeys', key, this.httpOptions)
.pipe(catchError(this.handleError));
}
putApiKey(key: ApiKey): Observable<ApiKey> {
return this.http.put<ApiKey>(this.url + '/apikeys/' + key.id, key, this.httpOptions)
.pipe(catchError(this.handleError));
}
deleteApiKey(id: string): Observable<any> {
return this.http.delete<any>(this.url + '/apikeys/' + id, this.httpOptions)
.pipe(catchError(this.handleError));
}
getInvites(): Observable<Invite[]> {
return this.http.get<Invite[]>(this.url + '/orgs/' + this.orgId + '/invites', this.httpOptions)
.pipe(catchError(this.handleError));
}
postInvite(invite: Invite): Observable<Invite> {
return this.http.post<Invite>(this.url + '/orgs/' + this.orgId + '/invites', invite, this.httpOptions)
.pipe(catchError(this.handleError));
}
putInvite(invite: Invite): Observable<Invite> {
return this.http.put<Invite>(this.url + '/orgs/' + this.orgId + '/invites/' + invite.id, invite, this.httpOptions)
.pipe(catchError(this.handleError));
}
deleteInvite(id: string): Observable<any> {
return this.http.delete<any>(this.url + '/orgs/' + this.orgId + '/invites/' + id, this.httpOptions)
.pipe(catchError(this.handleError));
}
handleError(error: HttpErrorResponse) {
if (error.error instanceof ErrorEvent) {
// A client-side or network error occurred. Handle it accordingly.
logger.error('An error occurred:', error.error.message);
return new ErrorObservable(new AppError(error.error.message));
} else {
// The backend returned an unsuccessful response code.
// The response body may contain clues as to what went wrong,
logger.error(
`Backend returned code ${error.status}, ` +
`body was: ${error.error}`);
logger.error(error);
logger.error(error.error.error);
let appError: AppError;
if(error.error.error) {
appError = new AppError(error.error.error, error.status);
} else if(error.message) {
appError = new AppError(error.message, error.status);
} else {
appError = new AppError('An unexpected error has occurred');
}
return new ErrorObservable(appError);
}
};
}

View File

@@ -0,0 +1,27 @@
import { Injectable } from '@angular/core';
import { ApiKey } from '../shared/apikey';
import { ApiService } from './api.service';
import { Observable } from 'rxjs/Observable';
@Injectable()
export class ApiKeyService {
constructor(private apiService: ApiService) {
}
getApiKeys(): Observable<ApiKey[]> {
return this.apiService.getApiKeys();
}
newApiKey(key: ApiKey): Observable<ApiKey> {
return this.apiService.postApiKey(key);
}
putApiKey(key: ApiKey): Observable<ApiKey> {
return this.apiService.putApiKey(key)
}
deleteApiKey(id: string): Observable<any> {
return this.apiService.deleteApiKey(id);
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable } from '@angular/core';
import { ApiService } from './api.service';
import { Org } from '../shared/org';
import { Observable } from 'rxjs/Observable';
import 'rxjs/add/observable/of';
@Injectable()
export class ConfigService {
private config: any;
constructor() {}
init(): Observable<any> {
return this.load();
}
get(key: string): any {
return this.config[key];
}
put(key: string, value: any) {
this.config[key] = value;
this.save();
}
clear() {
this.config = {
server: this.config.server
};
this.save();
}
save(): Observable<any> {
localStorage.setItem('config', JSON.stringify(this.config));
return Observable.of(this.config);
}
load(): Observable<any> {
try {
this.config = JSON.parse(localStorage.getItem('config')) || {};
} catch(e) {
this.config = {};
}
return Observable.of(null);
}
}

View File

@@ -0,0 +1,52 @@
import { ModuleWithProviders, NgModule, Optional, SkipSelf } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Logger } from './logger';
import { ApiService } from './api.service';
//import { DataService } from './data.service';
import { AccountService } from './account.service';
import { ConfigService } from './config.service';
import { OrgService } from './org.service';
import { SessionService } from './session.service';
import { TransactionService } from './transaction.service';
import { UserService } from './user.service';
import { PriceService } from './price.service';
import { WebSocketService } from './websocket.service';
import { ApiKeyService } from './apikey.service';
import { HttpClientModule } from '@angular/common/http';
@NgModule({
imports: [CommonModule, HttpClientModule],
declarations: [],
exports: [],
providers: []
})
export class CoreModule {
constructor (@Optional() @SkipSelf() parentModule: CoreModule) {
if (parentModule) {
throw new Error(
'CoreModule is already loaded. Import it in the AppModule only');
}
}
static forRoot(): ModuleWithProviders {
return {
ngModule: CoreModule,
providers: [
Logger,
ApiService,
AccountService,
ConfigService,
OrgService,
SessionService,
TransactionService,
UserService,
PriceService,
WebSocketService,
ApiKeyService
]
};
}
}

50
src/app/core/logger.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Injectable } from '@angular/core';
@Injectable()
export class Logger {
private logLevel: number;
static FATAL: number = 0;
static ERROR: number = 1;
static INFO: number = 2;
static DEBUG: number = 3;
constructor() {
this.logLevel = Logger.INFO;
}
setLevel(logLevel: number) {
this.logLevel = logLevel;
}
fatal(...params: any[]) {
if(this.logLevel >= Logger.FATAL) {
params.unshift(new Date().toLocaleString());
console.error.apply(null, params);
}
}
error(...params: any[]) {
if(this.logLevel >= Logger.ERROR) {
params.unshift(new Date().toLocaleString());
console.error.apply(null, params);
}
}
info(...params: any[]) {
if(this.logLevel >= Logger.INFO) {
params.unshift(new Date().toLocaleString());
console.log.apply(null, params);
}
}
debug(...params: any[]) {
if(this.logLevel >= Logger.DEBUG) {
params.unshift(new Date().toLocaleString());
console.log.apply(null, params);
}
}
}

View File

@@ -0,0 +1,82 @@
import { Injectable } from '@angular/core';
import { Logger } from './logger';
import { ApiService } from './api.service';
import { SessionService } from './session.service';
import { ConfigService } from './config.service';
import { Org } from '../shared/org';
import { Invite } from '../shared/invite';
import { Observable } from 'rxjs/Observable';
import { SessionOptions } from '../shared/session-options';
@Injectable()
export class OrgService {
private org: Org;
constructor(
private log: Logger,
private apiService: ApiService,
private sessionService: SessionService,
private configService: ConfigService) {
this.log.debug('orgService constructor');
this.sessionService.getSessions().subscribe(([user, org]) => {
this.log.debug('orgService: new session');
this.org = org;
});
}
getOrg(id: string): Observable<Org> {
return this.apiService.getOrg(id);
}
getCurrentOrg(): Org {
return this.org;
}
getOrgs(): Observable<Org[]> {
return this.apiService.getOrgs();
}
newOrg(org: Org, createDefaultAccounts: boolean): Observable<Org> {
let sessionOptions = new SessionOptions({
createDefaultAccounts: createDefaultAccounts
});
return this.apiService.postOrg(org)
.do(org => {
this.org = org;
this.configService.put('defaultOrg', this.org.id);
this.sessionService.switchOrg(this.org, sessionOptions);
});
}
selectOrg(id: string): Observable<Org> {
return this.getOrg(id)
.do(org => {
this.org = org;
this.configService.put('defaultOrg', this.org.id);
this.sessionService.switchOrg(this.org);
});
}
getInvites(): Observable<Invite[]> {
return this.apiService.getInvites();
}
newInvite(invite: Invite): Observable<Invite> {
return this.apiService.postInvite(invite);
}
acceptInvite(inviteId: string): Observable<Invite> {
let invite = new Invite({
id: inviteId,
accepted: true
});
return this.apiService.putInvite(invite);
}
deleteInvite(inviteId: string): Observable<any> {
return this.apiService.deleteInvite(inviteId);
}
}

View File

@@ -0,0 +1,107 @@
import { Injectable } from '@angular/core';
import { Logger } from './logger';
import { ApiService } from './api.service';
import { SessionService } from './session.service';
import { WebSocketService } from './websocket.service';
import { Price } from '../shared/price';
import { Org } from '../shared/org';
import { Message } from '../shared/message';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/merge';
import { Util } from '../shared/util';
@Injectable()
export class PriceService {
private org: Org;
private priceSubscription: Subscription;
private newPrices: Subject<Price>;
private deletedPrices: Subject<Price>;
constructor(
private log: Logger,
private apiService: ApiService,
private wsService: WebSocketService,
private sessionService: SessionService) {
this.newPrices = new Subject<Price>();
this.deletedPrices = new Subject<Price>();
this.sessionService.getSessions().subscribe(([user, org]) => {
this.log.debug('priceService new session');
// cleanup after old session
if(this.priceSubscription) {
this.wsService.unsubscribe('price', this.org.id);
this.priceSubscription.unsubscribe();
this.priceSubscription = null;
}
this.org = org;
if(org) {
// subscribe to web socket
let priceWs$ = this.wsService.subscribe('price', org.id);
this.priceSubscription = priceWs$.subscribe(message => {
let price = null;
if(message.data) {
price = new Price(message.data);
}
switch(message.action) {
case 'create':
this.newPrices.next(price);
break;
case 'delete':
this.deletedPrices.next(price);
break;
}
});
}
});
}
getNewPrices(): Observable<Price> {
return this.newPrices.asObservable();
}
getDeletedPrices(): Observable<Price> {
return this.deletedPrices.asObservable();
}
getPricesNearestInTime(date: Date): Observable<Price[]> {
// TODO make more efficient by mutating state as needed instead of full api call
// on every price change
let newPrices$ = this.getNewPrices();
let deletedPrices$ = this.getDeletedPrices();
let stream$ = Observable.of(null).concat(newPrices$.merge(deletedPrices$));
return stream$.switchMap(() => {
return this.apiService.getPricesNearestInTime(date);
});
}
getPricesByCurrency(currency: string): Observable<Price[]> {
return this.apiService.getPricesByCurrency(currency);
}
newPrice(price: Price): Observable<Price> {
return this.apiService.postPrice(price);
}
deletePrice(id: string): Observable<any> {
return this.apiService.deletePrice(id);
}
updatePrice(price: Price): Observable<Price> {
return this.apiService.deletePrice(price.id).switchMap(() => {
let newPrice = new Price(price);
newPrice.id = Util.newGuid();
return this.apiService.postPrice(newPrice);
});
}
}

View File

@@ -0,0 +1,169 @@
import { Injectable } from '@angular/core';
import { Logger } from './logger';
import { User } from '../shared/user';
import { Org } from '../shared/org';
import { SessionOptions } from '../shared/session-options';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import { ConfigService } from './config.service';
import { ApiService } from './api.service';
import { WebSocketService } from './websocket.service';
import 'rxjs/add/operator/catch';
import 'rxjs/add/operator/switchMap';
import 'rxjs/add/operator/map';
@Injectable()
export class SessionService {
private sessions$: Subject<[User, Org, SessionOptions]>;
private user: User;
private org: Org;
private loading: boolean;
constructor(
private log: Logger,
private apiService: ApiService,
private configService: ConfigService,
private wsService: WebSocketService) {
this.loading = true;
this.sessions$ = new Subject<[User, Org, SessionOptions]>();
}
getSessions(): Observable<[User, Org, SessionOptions]> {
return this.sessions$.asObservable();
}
login(email: string, password: string, sessionId: string): Observable<any> {
return this.apiService.newSession(email, password, sessionId).do(() => {
this.init(sessionId);
});
}
init(sessionId?: string) {
this.loading = true;
let server = this.configService.get('server');
if(!server) {
server = 'https://openaccounting.io:8080/api';
this.configService.put('server', server);
}
this.apiService.setUrl(server || 'https://openaccounting.io:8080/api');
sessionId = sessionId || this.configService.get('sessionId');
let orgId = this.configService.get('defaultOrg');
if(!sessionId) {
this.loading = false;
return this.sessions$.next([null, null, new SessionOptions()]);
}
this.apiService.setSession(sessionId);
this.apiService.getUser()
.catch(err => {
this.log.debug('bad session ' + err);
this.apiService.removeSessionInfo();
this.configService.clear();
this.loading = false;
return Observable.of(null);
})
.switchMap(user => {
if(!user) {
this.loading = false;
return Observable.of([null, null, new SessionOptions]);
}
return this.apiService.getOrg(orgId).map(org => {
return [user, org];
}).catch(err => {
this.loading = false;
this.log.debug('catching error here');
return this.apiService.getOrgs().map(orgs => {
if(orgs.length) {
let org = orgs[0];
this.configService.put('defaultOrg', org.id);
return [user, org];
}
return [user, null];
})
})
})
.subscribe(([user, org]) => {
this.log.debug('new session');
this.log.debug(user);
this.log.debug(org);
this.user = user;
this.org = org;
if(org) {
this.apiService.setOrgId(org.id);
}
// initialize websocket
let matches = server.match(/\/\/(.+?)\//);
if(matches[1]) {
let url = 'wss://' +
matches[1] +
'/ws';
this.wsService.init(url, sessionId);
} else {
this.log.debug('Failed to initialize web socket because we can\'t parse server url');
}
this.loading = false;
this.sessions$.next([user, org, new SessionOptions()]);
})
}
logout() {
setTimeout(() => {
this.wsService.close();
this.apiService.logout();
this.log.debug('new session');
this.log.debug(null);
this.log.debug(null);
this.sessions$.next([null, null, new SessionOptions()]);
}, 1);
}
switchOrg(org: Org, options?: SessionOptions) {
setTimeout(() => {
if(!options) {
options = new SessionOptions();
}
this.org = org;
this.apiService.setOrgId(org.id);
this.log.debug('new session');
this.log.debug(this.user);
this.log.debug(org);
this.sessions$.next([this.user, org, options]);
}, 1);
}
setLoading(loading) {
setTimeout(() => {
this.loading = loading;
}, 1);
}
isLoading() {
return this.loading;
}
getUser() {
return this.user;
}
getOrg() {
return this.org;
}
}

View File

@@ -0,0 +1,228 @@
import { Injectable } from '@angular/core';
import { Logger } from './logger';
import { ApiService } from './api.service';
import { WebSocketService } from './websocket.service';
import { SessionService } from './session.service';
import { Observable } from 'rxjs/Observable';
import { Subscription } from 'rxjs/Subscription';
import { Subject } from 'rxjs/Subject';
import { Transaction } from '../shared/transaction';
import { Account } from '../shared/account';
import { Org } from '../shared/org';
import { Message } from '../shared/message';
import 'rxjs/add/operator/do';
import 'rxjs/add/operator/merge';
import 'rxjs/add/operator/filter';
@Injectable()
export class TransactionService {
private transactionLastUpdated: Date;
private cache: any;
private recentTransactions: Transaction[] = null;
private newTxs: Subject<Transaction>;
private deletedTxs: Subject<Transaction>;
private org: Org;
private txSubscription: Subscription;
constructor(
private log: Logger,
private apiService: ApiService,
private wsService: WebSocketService,
private sessionService: SessionService) {
this.newTxs = new Subject<Transaction>();
this.deletedTxs = new Subject<Transaction>();
this.transactionLastUpdated = new Date(0);
this.sessionService.getSessions().subscribe(([user, org]) => {
this.log.debug('transactionService new session');
// cleanup from old session
if(this.txSubscription) {
this.wsService.unsubscribe('transaction', this.org.id);
this.txSubscription.unsubscribe();
this.txSubscription = null;
}
this.org = org;
if(org) {
this.recentTransactions = null;
let txMessages$ = this.wsService.subscribe('transaction', org.id);
this.txSubscription = txMessages$.subscribe(message => {
let tx = null;
if(message.data) {
tx = new Transaction(message.data);
}
if(tx && tx.updated) {
this.transactionLastUpdated = tx.updated;
}
switch(message.action) {
case 'create':
if(this.recentTransactions) {
this.recentTransactions.push(tx);
}
this.newTxs.next(tx);
break;
case 'update':
if(this.recentTransactions) {
for(let i = 0; i < this.recentTransactions.length; i++) {
if(this.recentTransactions[i].id === tx.id) {
this.recentTransactions[i] = tx;
break;
}
}
}
this.deletedTxs.next(tx);
this.newTxs.next(tx);
break;
case 'delete':
if(this.recentTransactions) {
for(let i = 0; i < this.recentTransactions.length; i++) {
if(this.recentTransactions[i].id === tx.id) {
this.recentTransactions.splice(i, 1);
break;
}
}
}
this.deletedTxs.next(tx);
break;
case 'reconnect':
this.log.debug('Resyncing transactions');
this.log.debug('Fetching transactions since ' + this.transactionLastUpdated);
let options = {sinceUpdated: this.transactionLastUpdated.getTime(), sort: 'updated-asc', includeDeleted: 'true'};
this.apiService.getTransactions(options).subscribe(txs => {
txs.forEach(tx => {
this.transactionLastUpdated = tx.updated;
if(tx.deleted) {
if(this.recentTransactions) {
for(let i = 0; i < this.recentTransactions.length; i++) {
if(this.recentTransactions[i].id === tx.id) {
this.recentTransactions.splice(i, 1);
break;
}
}
}
this.deletedTxs.next(tx);
} else {
if(this.recentTransactions) {
this.recentTransactions.push(tx);
}
this.newTxs.next(tx);
}
});
});
break;
}
});
}
});
}
getNewTransactions(): Observable<Transaction> {
return this.newTxs.asObservable();
}
getDeletedTransactions(): Observable<Transaction> {
return this.deletedTxs.asObservable();
}
getRecentTransactions(): Observable<Transaction[]> {
if(this.recentTransactions) {
return Observable.of(this.recentTransactions);
}
return this.apiService.getTransactions({limit: 50}).do(transactions => {
this.recentTransactions = transactions;
transactions.forEach(tx => {
if(tx.updated > this.transactionLastUpdated) {
this.transactionLastUpdated = tx.updated;
}
});
});
}
getLastTransactions(count: number): Observable<Transaction[]> {
return this.getRecentTransactions()
.map(txs => {
return txs.sort((a, b) => {
return b.date.getTime() - a.date.getTime();
});
})
.map(txs => {
return txs.slice(0, count);
})
.switchMap(initialTxs => {
let txs = initialTxs;
return Observable.of(initialTxs)
.concat(this.getNewTransactions()
.map(tx => {
// TODO check date
txs.unshift(tx);
txs.pop();
return txs;
}).merge(this.getDeletedTransactions()
.map(tx => {
for(let i = 0; i < txs.length; i++) {
if(txs[i].id === tx.id) {
txs.splice(i, 1);
}
}
return txs;
})
)
);
});
}
getNewTransactionsByAccount(accountId: string): Observable<Transaction> {
return this.getNewTransactions().filter(tx => {
for(let split of tx.splits) {
if(split.accountId === accountId) {
return true;
}
}
return false;
});
}
getDeletedTransactionsByAccount(accountId: string): Observable<Transaction> {
return this.getDeletedTransactions().filter(tx => {
for(let split of tx.splits) {
if(split.accountId === accountId) {
return true;
}
}
return false;
});
}
getTransactionsByAccount (accountId: string, options: any = {}): Observable<Transaction[]> {
return this.apiService.getTransactionsByAccount(accountId, options);
}
getTransactions(options: any = {}): Observable<Transaction[]> {
return this.apiService.getTransactions(options);
}
newTransaction(transaction: Transaction): Observable<Transaction> {
return this.apiService.postTransaction(transaction);
}
putTransaction(oldId: string, transaction: Transaction): Observable<Transaction> {
return this.apiService.putTransaction(oldId, transaction);
}
deleteTransaction(id: string): Observable<any> {
return this.apiService.deleteTransaction(id);
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core';
import { ApiService } from './api.service';
import { Observable } from 'rxjs/Observable';
import { User } from '../shared/user';
@Injectable()
export class UserService {
private user: User;
constructor(private apiService: ApiService) {
}
getUser(): Observable<User> {
return this.apiService.getUser();
}
postUser(user: User): Observable<User> {
return this.apiService.postUser(user);
}
putUser(user: User): Observable<User> {
return this.apiService.putUser(user);
}
verifyUser(code: string): Observable<any> {
return this.apiService.verifyUser(code);
}
resetPassword(email: string): Observable<any> {
return this.apiService.resetPassword(email);
}
confirmResetPassword(password: string, code: string): Observable<User> {
return this.apiService.confirmResetPassword(password, code);
}
}

View File

@@ -0,0 +1,235 @@
import { Injectable } from '@angular/core';
import { Logger } from './logger';
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http';
import { WebSocketSubject } from 'rxjs/observable/dom/WebSocketSubject';
import { Message } from '../shared/message';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Subject';
import 'rxjs/add/operator/filter';
import 'rxjs/add/operator/retryWhen';
import 'rxjs/add/operator/repeatWhen';
import 'rxjs/add/operator/delay';
var version = '^0.1.8';
@Injectable()
export class WebSocketService {
private socket$: WebSocketSubject<Message>;
private outputSocket$: Subject<Message>;
private subscriptions: Message[];
private reconnected: boolean;
private sequenceNumber: number;
private lastPongDate: Date;
private closed: boolean;
private authErrorCount: number;
constructor(private log: Logger) {
this.reconnected = false;
this.subscriptions = [];
this.outputSocket$ = new Subject<Message>();
this.authErrorCount = 0;
}
init(url: string, key: string) {
this.closed = false;
this.socket$ = new WebSocketSubject({
url: url,
openObserver: {
next: value => {
this.log.debug('websocket connected!');
this.sequenceNumber = -1;
this.detectSleep();
if(this.reconnected) {
this.authenticate(key);
this.sendReconnectMessage();
this.log.debug('resubscribing to events');
this.subscriptions.forEach(message => {
this.log.debug(message);
this.socket$.next(message);
})
}
}
},
closeObserver: {
next: value => {
this.log.debug('websocket closed!');
this.log.debug(value);
if(value.code === 4000) {
// authentication error
// this could be because the socket got reconnected and we need
// to send an authenticate message
this.authErrorCount++;
if(this.authErrorCount >= 3) {
this.closed = true;
}
}
if(value.code >= 4001) {
// other intentional errors we should just stop trying to reconnect
this.closed = true;
}
}
}
});
this.socket$.retryWhen(errors$ => {
if(this.closed) {
throw new Error('closed');
}
return errors$.delay(1000).do(err => {
this.log.debug('Websocket error');
this.log.debug(err);
this.reconnected = true;
});
}).repeatWhen(completed => {
if(this.closed) {
throw new Error('closed');
}
return completed.delay(1000).do(err => {
this.log.debug('Reconnecting to websocket because it closed');
this.reconnected = true;
})
}).subscribe(message => {
this.log.debug('Received message. Our sequenceNumber is ' + this.sequenceNumber);
this.log.debug(message);
this.authErrorCount = 0;
if(message.type === 'pong') {
this.lastPongDate = new Date();
}
if(message.sequenceNumber === 0 && this.sequenceNumber > 0) {
// reconnected on us
this.log.debug('Websocket reconnected on us');
this.authenticate(key);
this.sendReconnectMessage();
this.sequenceNumber = 0;
return;
} else if(message.sequenceNumber !== this.sequenceNumber + 1) {
// got a bad sequence number
// need to reconnect and resync
this.log.debug('Websocket out of sync');
this.socket$.error({code: 3791, reason: 'Out of sync'});
return;
}
this.sequenceNumber = message.sequenceNumber;
this.outputSocket$.next(message);
}, err => {
this.log.error(err);
}, () => {
this.log.debug('Websocket complete.');
});
this.authenticate(key);
}
subscribe(type: string, orgId: string): Observable<Message> {
let message = new Message({
version: version,
sequenceNumber: -1,
type: type,
action: 'subscribe',
data: orgId
});
this.socket$.next(message);
this.subscriptions.push(message);
return this.outputSocket$.filter(message => {
return message.type === type || message.type === 'reconnect';
});
}
unsubscribe(type: string, orgId: string) {
let message = new Message({
version: version,
sequenceNumber: -1,
type: type,
action: 'unsubscribe',
data: orgId
});
this.socket$.next(message);
this.subscriptions = this.subscriptions.filter(message => {
return !(message.type === type && message.data === orgId);
});
}
detectSleep() {
let lastDate = new Date();
let interval = setInterval(() => {
let currentDate = new Date();
if(currentDate.getTime() - lastDate.getTime() > 10000) {
// Detected sleep
this.log.debug('Sleep detected! Sending ping.');
let date = new Date();
let message = new Message({
version: version,
sequenceNumber: -1,
type: 'ping',
action: 'ping',
data: null
});
this.socket$.next(message);
setTimeout(() => {
this.checkForPong(date);
}, 5000);
}
lastDate = currentDate;
}, 5000);
}
checkForPong(date: Date) {
if(!this.lastPongDate || this.lastPongDate.getTime() < date.getTime()) {
this.log.debug('no pong response');
this.socket$.error({code: 3792, reason: 'No pong response'});
}
}
sendReconnectMessage() {
this.log.debug('notifiyng subscribers of reconnect event');
let message = new Message({
version: version,
sequenceNumber: -1,
type: 'reconnect',
action: 'reconnect',
data: null
});
this.outputSocket$.next(message);
}
close() {
this.log.debug('Closed websocket');
this.closed = true;
this.socket$.unsubscribe();
}
authenticate(key: string) {
let message = new Message({
version: version,
sequenceNumber: -1,
type: 'authenticate',
action: 'authenticate',
data: key
});
this.socket$.next(message);
}
}